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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
170 changed files with 15837 additions and 3158 deletions

View File

@ -81,6 +81,7 @@
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.1" />
<PackageReference Include="Polly" Version="8.6.2" />
<PackageReference Include="Quill.Delta" Version="1.0.7" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
@ -91,7 +92,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.15.0.120848">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
public class AnnotationController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<AnnotationController> _logger;
private readonly IBookService _bookService;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
public AnnotationController(IUnitOfWork unitOfWork, ILogger<AnnotationController> logger,
IBookService bookService, ILocalizationService localizationService, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_logger = logger;
_bookService = bookService;
_localizationService = localizationService;
_eventHub = eventHub;
}
/// <summary>
/// Returns the annotations for the given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
/// <summary>
/// Returns the Annotation by Id. User must have access to annotation.
/// </summary>
/// <param name="annotationId"></param>
/// <returns></returns>
[HttpGet("{annotationId}")]
public async Task<ActionResult<AnnotationDto>> GetAnnotation(int annotationId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId));
}
/// <summary>
/// Create a new Annotation for the user against a Chapter
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult<AnnotationDto>> CreateAnnotation(AnnotationDto dto)
{
try
{
if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText))
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "invalid-payload"));
}
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var chapterTitle = string.Empty;
try
{
var toc = await _bookService.GenerateTableOfContents(chapter);
var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber);
if (pageTocs.Count > 0)
{
chapterTitle = pageTocs[0].Title;
}
}
catch (KavitaException)
{
/* Swallow */
}
var annotation = new AppUserAnnotation()
{
XPath = dto.XPath,
EndingXPath = dto.EndingXPath,
ChapterId = dto.ChapterId,
SeriesId = dto.SeriesId,
VolumeId = dto.VolumeId,
LibraryId = dto.LibraryId,
HighlightCount = dto.HighlightCount,
SelectedText = dto.SelectedText,
Comment = dto.Comment,
ContainsSpoiler = dto.ContainsSpoiler,
PageNumber = dto.PageNumber,
SelectedSlotIndex = dto.SelectedSlotIndex,
AppUserId = User.GetUserId(),
Context = dto.Context,
ChapterTitle = chapterTitle
};
_unitOfWork.AnnotationRepository.Attach(annotation);
await _unitOfWork.CommitAsync();
return Ok(await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id));
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-failed-create"));
}
}
/// <summary>
/// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update")]
public async Task<ActionResult<AnnotationDto>> UpdateAnnotation(AnnotationDto dto)
{
try
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id);
if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest();
annotation.ContainsSpoiler = dto.ContainsSpoiler;
annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
annotation.Comment = dto.Comment;
_unitOfWork.AnnotationRepository.Update(annotation);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, MessageFactory.AnnotationUpdateEvent(dto),
User.GetUserId());
return Ok(dto);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber);
return BadRequest();
}
return Ok();
}
/// <summary>
/// Delete the annotation for the user
/// </summary>
/// <param name="annotationId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> DeleteAnnotation(int annotationId)
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(annotationId);
if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-delete"));
_unitOfWork.AnnotationRepository.Remove(annotation);
await _unitOfWork.CommitAsync();
return Ok();
}
}

View File

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

View File

@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Reader;
using API.Entities.Enums;
@ -34,33 +35,41 @@ public class BookController : BaseApiController
}
/// <summary>
/// Retrieves information for the PDF and Epub reader
/// Retrieves information for the PDF and Epub reader. This will cache the file.
/// </summary>
/// <remarks>This only applies to Epub or PDF files</remarks>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("{chapterId}/book-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])]
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
{
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var bookTitle = string.Empty;
switch (dto.SeriesFormat)
{
case MangaFormat.Epub:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
await _cacheService.Ensure(chapterId);
var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath);
using var book = await EpubReader.OpenBookAsync(file, BookService.LenientBookReaderOptions);
bookTitle = book.Title;
break;
}
case MangaFormat.Pdf:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
await _cacheService.Ensure(chapterId);
var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath);
if (string.IsNullOrEmpty(bookTitle))
{
// Override with filename
bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
bookTitle = Path.GetFileNameWithoutExtension(file);
}
break;
@ -72,9 +81,9 @@ public class BookController : BaseApiController
break;
}
return Ok(new BookInfoDto()
var info = new BookInfoDto()
{
ChapterNumber = dto.ChapterNumber,
ChapterNumber = dto.ChapterNumber,
VolumeNumber = dto.VolumeNumber,
VolumeId = dto.VolumeId,
BookTitle = bookTitle,
@ -84,7 +93,10 @@ public class BookController : BaseApiController
LibraryId = dto.LibraryId,
IsSpecial = dto.IsSpecial,
Pages = dto.Pages,
});
};
return Ok(info);
}
/// <summary>
@ -157,7 +169,11 @@ public class BookController : BaseApiController
try
{
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl));
var ptocBookmarks =
await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
var annotations = await _unitOfWork.UserRepository.GetAnnotationsByPage(User.GetUserId(), chapter.Id, page);
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations));
}
catch (KavitaException ex)
{

View File

@ -193,13 +193,12 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"
])]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey", "imageOffset"])]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey, int imageOffset = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId);
var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, imageOffset, userId);
if (bookmark == null) return BadRequest(await _localizationService.Translate(userId, "bookmark-doesnt-exist"));
var bookmarkDirectory =

View File

@ -38,7 +38,7 @@ namespace API.Controllers;
#nullable enable
/**
* Middleware that checks if Opds has been enabled for this server
* Middleware that checks if Opds has been enabled for this server, and sets OpdsController.UserId in HttpContext
*/
[AttributeUsage(AttributeTargets.Class)]
public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger<OpdsController> logger): ActionFilterAttribute
@ -49,14 +49,13 @@ public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationServ
int userId;
try
{
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) ||
apiKeyObj is not string apiKey || context.Controller is not OpdsController controller)
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
{
context.Result = new BadRequestResult();
return;
}
userId = await controller.GetUser(apiKey);
userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == null || userId == 0)
{
context.Result = new UnauthorizedResult();
@ -1346,7 +1345,7 @@ public class OpdsController : BaseApiController
/// Gets the user from the API key
/// </summary>
/// <returns></returns>
public async Task<int> GetUser(string apiKey)
private async Task<int> GetUser(string apiKey)
{
try
{

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));
}
}

View File

@ -185,6 +185,11 @@ public class SeriesController : BaseApiController
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
/// <summary>
/// All chapter entities will load this data by default. Will not be maintained as of v0.8.1
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")]
[HttpGet("chapter-metadata")]
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)

View File

@ -109,6 +109,7 @@ public class UsersController : BaseApiController
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
if (await _licenseService.HasActiveLicense())
{

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.Reader;
/// <summary>
/// Represents an annotation on a book
/// </summary>
public sealed record AnnotationDto
{
public int Id { get; set; }
/// <summary>
/// Starting point of the Highlight
/// </summary>
public required string XPath { get; set; }
/// <summary>
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
/// </summary>
public string EndingXPath { get; set; }
/// <summary>
/// The text selected.
/// </summary>
public string SelectedText { get; set; }
/// <summary>
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// Title of the TOC Chapter within Epub (not Chapter Entity)
/// </summary>
public string? ChapterTitle { get; set; }
/// <summary>
/// A calculated selection of the surrounding text. This does not update after creation.
/// </summary>
public string? Context { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
public bool ContainsSpoiler { get; set; }
public int PageNumber { get; set; }
/// <summary>
/// Selected Highlight Slot Index [0-4]
/// </summary>
public int SelectedSlotIndex { get; set; }
public required int ChapterId { get; set; }
public required int VolumeId { get; set; }
public required int SeriesId { get; set; }
public required int LibraryId { get; set; }
public required int OwnerUserId { get; set; }
public string OwnerUsername { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View File

@ -15,7 +15,19 @@ public sealed record BookmarkDto
[Required]
public int ChapterId { get; set; }
/// <summary>
/// Only applicable for Epubs
/// </summary>
public int ImageOffset { get; set; }
/// <summary>
/// Only applicable for Epubs
/// </summary>
public string? XPath { get; set; }
/// <summary>
/// This is only used when getting all bookmarks.
/// </summary>
public SeriesDto? Series { get; set; }
/// <summary>
/// Not required, will be filled out at API before saving to the DB
/// </summary>
public string? ChapterTitle { get; set; }
}

View File

@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto
public required int PageNumber { get; set; }
public required string Title { get; set; }
public string? BookScrollId { get; set; }
public string? SelectedText { get; set; }
}

View File

@ -4,8 +4,27 @@
public sealed record PersonalToCDto
{
public required int Id { get; init; }
public required int ChapterId { get; set; }
/// <summary>
/// The page to bookmark
/// </summary>
public required int PageNumber { get; set; }
/// <summary>
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
/// </summary>
public required string Title { get; set; }
/// <summary>
/// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page
/// </summary>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
/// <summary>
/// Title of the Chapter this PToC was created in
/// </summary>
/// <remarks>Taken from the ToC</remarks>
public string? ChapterTitle { get; set; }
}

View File

@ -23,6 +23,7 @@ public sealed record SearchResultGroupDto
public IEnumerable<MangaFileDto> Files { get; set; } = default!;
public IEnumerable<ChapterDto> Chapters { get; set; } = default!;
public IEnumerable<BookmarkSearchResultDto> Bookmarks { get; set; } = default!;
public IEnumerable<AnnotationDto> Annotations { get; set; } = default!;
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
@ -41,4 +42,7 @@ public sealed record UserPreferencesDto
public bool AniListScrobblingEnabled { get; set; }
/// <inheritdoc cref="API.Entities.AppUserPreferences.WantToReadSync"/>
public bool WantToReadSync { get; set; }
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderHighlightSlots"/>
[Required]
public List<HighlightSlot> BookReaderHighlightSlots { get; set; }
}

View File

@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
@ -301,6 +302,14 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<AppUserPreferences>()
.Property(a => a.BookReaderHighlightSlots)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<HighlightSlot>>(v, JsonSerializerOptions.Default) ?? new List<HighlightSlot>())
.HasColumnType("TEXT")
.HasDefaultValue(new List<HighlightSlot>());
builder.Entity<AppUser>()
.Property(user => user.IdentityProvider)
.HasDefaultValue(IdentityProvider.Kavita);

View File

@ -0,0 +1,100 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Entities.History;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.8 - Switch existing xpaths saved to a descoped version
/// </summary>
public static class ManualMigrateBookReadingProgress
{
/// <summary>
/// Scope from 2023 era before a DOM change
/// </summary>
private const string OldScope = "//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/";
/// <summary>
/// Scope from post DOM change
/// </summary>
private const string NewScope = "//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[3]/";
/// <summary>
/// New-descoped prefix
/// </summary>
private const string ReplacementScope = "//BODY/DIV[1]";
public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateBookReadingProgress"))
{
return;
}
logger.LogCritical("Running ManualMigrateBookReadingProgress migration - Please be patient, this may take some time. This is not an error");
var bookProgress = await context.AppUserProgresses
.Where(p => p.BookScrollId != null && (p.BookScrollId.StartsWith(OldScope) || p.BookScrollId.StartsWith(NewScope)))
.ToListAsync();
foreach (var progress in bookProgress)
{
if (string.IsNullOrEmpty(progress.BookScrollId)) continue;
if (progress.BookScrollId.StartsWith(OldScope))
{
progress.BookScrollId = progress.BookScrollId.Replace(OldScope, ReplacementScope);
context.AppUserProgresses.Update(progress);
} else if (progress.BookScrollId.StartsWith(NewScope))
{
progress.BookScrollId = progress.BookScrollId.Replace(NewScope, ReplacementScope);
context.AppUserProgresses.Update(progress);
}
}
if (unitOfWork.HasChanges())
{
await context.SaveChangesAsync();
}
var ptocEntries = await context.AppUserTableOfContent
.Where(p => p.BookScrollId != null && (p.BookScrollId.StartsWith(OldScope) || p.BookScrollId.StartsWith(NewScope)))
.ToListAsync();
foreach (var ptoc in ptocEntries)
{
if (string.IsNullOrEmpty(ptoc.BookScrollId)) continue;
if (ptoc.BookScrollId.StartsWith(OldScope))
{
ptoc.BookScrollId = ptoc.BookScrollId.Replace(OldScope, ReplacementScope);
context.AppUserTableOfContent.Update(ptoc);
} else if (ptoc.BookScrollId.StartsWith(NewScope))
{
ptoc.BookScrollId = ptoc.BookScrollId.Replace(NewScope, ReplacementScope);
context.AppUserTableOfContent.Update(ptoc);
}
}
if (unitOfWork.HasChanges())
{
await context.SaveChangesAsync();
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateBookReadingProgress",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateBookReadingProgress migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class BookAnnotations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ChapterTitle",
table: "AppUserTableOfContent",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SelectedText",
table: "AppUserTableOfContent",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BookReaderHighlightSlots",
table: "AppUserPreferences",
type: "TEXT",
nullable: true,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "ChapterTitle",
table: "AppUserBookmark",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImageOffset",
table: "AppUserBookmark",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "XPath",
table: "AppUserBookmark",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "AppUserAnnotation",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
XPath = table.Column<string>(type: "TEXT", nullable: true),
EndingXPath = table.Column<string>(type: "TEXT", nullable: true),
SelectedText = table.Column<string>(type: "TEXT", nullable: true),
Comment = table.Column<string>(type: "TEXT", nullable: true),
HighlightCount = table.Column<int>(type: "INTEGER", nullable: false),
PageNumber = table.Column<int>(type: "INTEGER", nullable: false),
SelectedSlotIndex = table.Column<int>(type: "INTEGER", nullable: false),
Context = table.Column<string>(type: "TEXT", nullable: true),
ContainsSpoiler = table.Column<bool>(type: "INTEGER", nullable: false),
ChapterTitle = table.Column<string>(type: "TEXT", nullable: true),
LibraryId = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserAnnotation", x => x.Id);
table.ForeignKey(
name: "FK_AppUserAnnotation_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserAnnotation_Chapter_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserAnnotation_AppUserId",
table: "AppUserAnnotation",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserAnnotation_ChapterId",
table: "AppUserAnnotation",
column: "ChapterId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserAnnotation");
migrationBuilder.DropColumn(
name: "ChapterTitle",
table: "AppUserTableOfContent");
migrationBuilder.DropColumn(
name: "SelectedText",
table: "AppUserTableOfContent");
migrationBuilder.DropColumn(
name: "BookReaderHighlightSlots",
table: "AppUserPreferences");
migrationBuilder.DropColumn(
name: "ChapterTitle",
table: "AppUserBookmark");
migrationBuilder.DropColumn(
name: "ImageOffset",
table: "AppUserBookmark");
migrationBuilder.DropColumn(
name: "XPath",
table: "AppUserBookmark");
}
}
}

View File

@ -162,6 +162,78 @@ namespace API.Data.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<bool>("ContainsSpoiler")
.HasColumnType("INTEGER");
b.Property<string>("Context")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("EndingXPath")
.HasColumnType("TEXT");
b.Property<int>("HighlightCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<int>("PageNumber")
.HasColumnType("INTEGER");
b.Property<int>("SelectedSlotIndex")
.HasColumnType("INTEGER");
b.Property<string>("SelectedText")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.Property<string>("XPath")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("ChapterId");
b.ToTable("AppUserAnnotation");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
@ -174,6 +246,9 @@ namespace API.Data.Migrations
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -183,6 +258,9 @@ namespace API.Data.Migrations
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<int>("ImageOffset")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
@ -198,6 +276,9 @@ namespace API.Data.Migrations
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.Property<string>("XPath")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
@ -434,6 +515,11 @@ namespace API.Data.Migrations
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderHighlightSlots")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("[]");
b.Property<bool>("BookReaderImmersiveMode")
.HasColumnType("INTEGER");
@ -834,6 +920,9 @@ namespace API.Data.Migrations
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -852,6 +941,9 @@ namespace API.Data.Migrations
b.Property<int>("PageNumber")
.HasColumnType("INTEGER");
b.Property<string>("SelectedText")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
@ -2834,6 +2926,25 @@ namespace API.Data.Migrations
b.ToTable("SeriesMetadataTag");
});
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Annotations")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -3620,6 +3731,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Annotations");
b.Navigation("Bookmarks");
b.Navigation("ChapterRatings");

View File

@ -0,0 +1,49 @@
using System.Threading.Tasks;
using API.DTOs.Reader;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
#nullable enable
public interface IAnnotationRepository
{
void Attach(AppUserAnnotation annotation);
void Update(AppUserAnnotation annotation);
void Remove(AppUserAnnotation annotation);
Task<AnnotationDto?> GetAnnotationDto(int id);
Task<AppUserAnnotation?> GetAnnotation(int id);
}
public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnotationRepository
{
public void Attach(AppUserAnnotation annotation)
{
context.AppUserAnnotation.Attach(annotation);
}
public void Update(AppUserAnnotation annotation)
{
context.AppUserAnnotation.Entry(annotation).State = EntityState.Modified;
}
public void Remove(AppUserAnnotation annotation)
{
context.AppUserAnnotation.Remove(annotation);
}
public async Task<AnnotationDto?> GetAnnotationDto(int id)
{
return await context.AppUserAnnotation
.ProjectTo<AnnotationDto>(mapper.ConfigurationProvider)
.FirstOrDefaultAsync(a => a.Id == id);
}
public async Task<AppUserAnnotation?> GetAnnotation(int id)
{
return await context.AppUserAnnotation
.FirstOrDefaultAsync(a => a.Id == id);
}
}

View File

@ -127,8 +127,8 @@ public class ChapterRepository : IChapterRepository
})
.Select(data => new ChapterInfoDto()
{
ChapterNumber = data.ChapterNumber + string.Empty, // TODO: Fix this
VolumeNumber = data.VolumeNumber + string.Empty, // TODO: Fix this
ChapterNumber = data.ChapterNumber + string.Empty,
VolumeNumber = data.VolumeNumber + string.Empty,
VolumeId = data.VolumeId,
IsSpecial = data.IsSpecial,
SeriesId = data.SeriesId,

View File

@ -16,6 +16,7 @@ using API.DTOs.Filtering.v2;
using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
@ -402,6 +403,14 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Annotations = await _context.AppUserAnnotation
.Where(a => a.AppUserId == userId &&
(EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") || EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%")))
.Take(maxRecords)
.OrderBy(l => l.CreatedUtc)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
var justYear = _yearRegex.Match(searchQuery).Value;
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;

View File

@ -75,7 +75,7 @@ public interface IUserRepository
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int imageOffset, int userId);
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
Task<int> GetUserIdByApiKeyAsync(string apiKey);
Task<AppUser?> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
@ -107,6 +107,8 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId);
Task<List<AnnotationDto>> GetAnnotationsByPage(int userId, int chapterId, int pageNum);
/// <summary>
/// Try getting a user by the id provided by OIDC
/// </summary>
@ -114,6 +116,8 @@ public interface IUserRepository
/// <param name="includes"></param>
/// <returns></returns>
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
Task<AnnotationDto?> GetAnnotationDtoById(int userId, int annotationId);
}
public class UserRepository : IUserRepository
@ -228,18 +232,18 @@ public class UserRepository : IUserRepository
return await _context.AppUserBookmark.ToListAsync();
}
public async Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId)
public async Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int imageOffset, int userId)
{
return await _context.AppUserBookmark
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId)
.SingleOrDefaultAsync();
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId && b.ImageOffset == imageOffset)
.FirstOrDefaultAsync();
}
public async Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId)
{
return await _context.AppUserBookmark
.Where(b => b.Id == bookmarkId)
.SingleOrDefaultAsync();
.FirstOrDefaultAsync();
}
@ -557,13 +561,39 @@ public class UserRepository : IUserRepository
/// </summary>
/// <param name="deviceEmail"></param>
/// <returns></returns>
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
public async Task<AppUser?> GetUserByDeviceEmail(string deviceEmail)
{
return await _context.AppUser
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
.FirstOrDefaultAsync();
}
/// <summary>
/// Returns a list of annotations ordered by page number.
/// </summary>
/// <param name="userId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId)
{
// TODO: Check settings if I should include other user's annotations
return await _context.AppUserAnnotation
.Where(a => a.AppUserId == userId && a.ChapterId == chapterId)
.OrderBy(a => a.PageNumber)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<List<AnnotationDto>> GetAnnotationsByPage(int userId, int chapterId, int pageNum)
{
// TODO: Check settings if I should include other user's annotations
return await _context.AppUserAnnotation
.Where(a => a.AppUserId == userId && a.ChapterId == chapterId && a.PageNumber == pageNum)
.OrderBy(a => a.PageNumber)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None)
{
if (string.IsNullOrEmpty(oidcId)) return null;
@ -574,6 +604,14 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync();
}
public async Task<AnnotationDto?> GetAnnotationDtoById(int userId, int annotationId)
{
return await _context.AppUserAnnotation
.Where(a => a.AppUserId == userId && a.Id == annotationId)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View File

@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository
void Remove(AppUserTableOfContent toc);
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page);
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
}
@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository
.AsEnumerable();
}
public async Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page)
{
return await _context.AppUserTableOfContent
.Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page)
.ProjectTo<PersonalToCDto>(_mapper.ConfigurationProvider)
.OrderBy(t => t.PageNumber)
.ToListAsync();
}
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
{
return await _context.AppUserTableOfContent

View File

@ -30,6 +30,45 @@ public static class Seed
/// </summary>
public static ImmutableArray<ServerSetting> DefaultSettings;
public static readonly ImmutableArray<HighlightSlot> DefaultHighlightSlots =
[
new()
{
Id = 1,
Title = "Cyan",
SlotNumber = 0,
Color = new RgbaColor { R = 0, G = 255, B = 255, A = 0.4f }
},
new()
{
Id = 2,
Title = "Green",
SlotNumber = 1,
Color = new RgbaColor { R = 0, G = 255, B = 0, A = 0.4f }
},
new()
{
Id = 3,
Title = "Yellow",
SlotNumber = 2,
Color = new RgbaColor { R = 255, G = 255, B = 0, A = 0.4f }
},
new()
{
Id = 4,
Title = "Orange",
SlotNumber = 3,
Color = new RgbaColor { R = 255, G = 165, B = 0, A = 0.4f }
},
new()
{
Id = 5,
Title = "Purple",
SlotNumber = 4,
Color = new RgbaColor { R = 255, G = 0, B = 255, A = 0.4f }
}
];
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
..new List<SiteTheme>
{
@ -45,8 +84,8 @@ public static class Seed
}.ToArray()
];
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
new List<AppUserDashboardStream>
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = [
..new List<AppUserDashboardStream>
{
new()
{
@ -80,38 +119,40 @@ public static class Seed
IsProvided = true,
Visible = false
},
}.ToArray());
}.ToArray()
];
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams = ImmutableArray.Create(
new AppUserSideNavStream()
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams =
[
new()
{
Name = "want-to-read",
StreamType = SideNavStreamType.WantToRead,
Order = 1,
IsProvided = true,
Visible = true
}, new AppUserSideNavStream()
}, new()
{
Name = "collections",
StreamType = SideNavStreamType.Collections,
Order = 2,
IsProvided = true,
Visible = true
}, new AppUserSideNavStream()
}, new()
{
Name = "reading-lists",
StreamType = SideNavStreamType.ReadingLists,
Order = 3,
IsProvided = true,
Visible = true
}, new AppUserSideNavStream()
}, new()
{
Name = "bookmarks",
StreamType = SideNavStreamType.Bookmarks,
Order = 4,
IsProvided = true,
Visible = true
}, new AppUserSideNavStream()
}, new()
{
Name = "all-series",
StreamType = SideNavStreamType.AllSeries,
@ -119,14 +160,15 @@ public static class Seed
IsProvided = true,
Visible = true
},
new AppUserSideNavStream()
new()
{
Name = "browse-authors",
StreamType = SideNavStreamType.BrowsePeople,
Order = 6,
IsProvided = true,
Visible = true
});
}
];
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
@ -215,59 +257,74 @@ public static class Seed
}
}
public static async Task SeedDefaultHighlightSlots(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences);
foreach (var user in allUsers)
{
if (user.UserPreferences.BookReaderHighlightSlots.Any()) break;
user.UserPreferences.BookReaderHighlightSlots = DefaultHighlightSlots.ToList();
unitOfWork.UserRepository.Update(user);
}
await unitOfWork.CommitAsync();
}
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
{
await context.Database.EnsureCreatedAsync();
DefaultSettings = ImmutableArray.Create(new List<ServerSetting>()
{
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"},
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
new()
DefaultSettings = [
..new List<ServerSetting>()
{
Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)
},
new()
{
Key = ServerSettingKey.Port, Value = Configuration.DefaultHttpPort + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() {
Key = ServerSettingKey.IpAddresses, Value = Configuration.DefaultIpAddresses
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new() {Key = ServerSettingKey.EnableOpds, Value = "true"},
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty},
new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"},
new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"},
new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()},
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
new() {Key = ServerSettingKey.TaskBackup, Value = "daily"},
new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"},
new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"},
new()
{
Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)
},
new()
{
Key = ServerSettingKey.Port, Value = Configuration.DefaultHttpPort + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() {
Key = ServerSettingKey.IpAddresses, Value = Configuration.DefaultIpAddresses
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new() {Key = ServerSettingKey.EnableOpds, Value = "true"},
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty},
new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"},
new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"},
new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()},
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"},
new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty},
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)},
}.ToArray());
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty},
new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty},
new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty},
new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"},
new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty},
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)},
}.ToArray()
];
foreach (var defaultSetting in DefaultSettings)
{

View File

@ -34,6 +34,7 @@ public interface IUnitOfWork
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
IEmailHistoryRepository EmailHistoryRepository { get; }
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
IAnnotationRepository AnnotationRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -76,6 +77,7 @@ public class UnitOfWork : IUnitOfWork
ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper);
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
AnnotationRepository = new AnnotationRepository(_context, _mapper);
}
/// <summary>
@ -106,6 +108,7 @@ public class UnitOfWork : IUnitOfWork
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
public IEmailHistoryRepository EmailHistoryRepository { get; }
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
public IAnnotationRepository AnnotationRepository { get; }
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -48,6 +48,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// A list of Table of Contents for a given Chapter
/// </summary>
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
public ICollection<AppUserAnnotation> Annotations { get; set; } = null!;
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.Entities;
/// <summary>
/// Represents an annotation in the Epub reader
/// </summary>
public class AppUserAnnotation : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Starting point of the Highlight
/// </summary>
public required string XPath { get; set; }
/// <summary>
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
/// </summary>
public string EndingXPath { get; set; }
/// <summary>
/// The text selected.
/// </summary>
public string SelectedText { get; set; }
/// <summary>
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
public int PageNumber { get; set; }
/// <summary>
/// Selected Highlight Slot Index [0-4]
/// </summary>
public int SelectedSlotIndex { get; set; }
/// <summary>
/// A calculated selection of the surrounding text. This does not update after creation.
/// </summary>
public string? Context { get; set; }
public bool ContainsSpoiler { get; set; }
// TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?)
/// <summary>
/// Title of the TOC Chapter within Epub (not Chapter Entity)
/// </summary>
public string? ChapterTitle { get; set; }
public required int LibraryId { get; set; }
public required int SeriesId { get; set; }
public required int VolumeId { get; set; }
public required int ChapterId { get; set; }
public Chapter Chapter { get; set; }
public required int AppUserId { get; set; }
public AppUser AppUser { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View File

@ -4,6 +4,7 @@ using API.Entities.Interfaces;
namespace API.Entities;
/// <summary>
/// Represents a saved page in a Chapter entity for a given user.
/// </summary>
@ -19,7 +20,19 @@ public class AppUserBookmark : IEntityDate
/// Filename in the Bookmark Directory
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// Only applicable for Epubs - handles multiple images on one page
/// </summary>
/// <remarks>0-based index of the image position on page</remarks>
public int ImageOffset { get; set; }
/// <summary>
/// Only applicable for Epubs
/// </summary>
public string? XPath { get; set; }
/// <summary>
/// Chapter name (from ToC) or Title (from ComicInfo/PDF)
/// </summary>
public string? ChapterTitle { get; set; }
// Relationships
[JsonIgnore]

View File

@ -108,6 +108,10 @@ public class AppUserPreferences
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Book Reader Option: A set of 5 distinct highlight slots with default colors. User can customize. Binds to all Highlight Annotations (<see cref="AppUserAnnotation"/>.
/// </summary>
public List<HighlightSlot> BookReaderHighlightSlots { get; set; }
#endregion
#region PdfReader

View File

@ -18,6 +18,19 @@ public class AppUserTableOfContent : IEntityDate
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
/// </summary>
public required string Title { get; set; }
/// <summary>
/// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page
/// </summary>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
/// <summary>
/// Title of the Chapter this PToC was created in
/// </summary>
/// <remarks>Taken from the ToC</remarks>
public string? ChapterTitle { get; set; }
public required int SeriesId { get; set; }
public virtual Series Series { get; set; }
@ -27,10 +40,7 @@ public class AppUserTableOfContent : IEntityDate
public int VolumeId { get; set; }
public int LibraryId { get; set; }
/// <summary>
/// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page
/// </summary>
public string? BookScrollId { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }

View File

@ -0,0 +1,11 @@
namespace API.Entities.Enums;
/// <summary>
/// Color of the highlight
/// </summary>
/// <remarks>Color may not match exactly due to theming</remarks>
public enum HightlightColor
{
Blue = 1,
Green = 2,
}

View File

@ -0,0 +1,20 @@
namespace API.Entities;
public sealed record HighlightSlot
{
public int Id { get; set; }
/// <summary>
/// Hex representation
/// </summary>
public string Title { get; set; }
public int SlotNumber { get; set; }
public RgbaColor Color { get; set; }
}
public struct RgbaColor
{
public int R { get; set; }
public int G { get; set; }
public int B { get; set; }
public float A { get; set; }
}

View File

@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using API.DTOs.Reader;
using HtmlAgilityPack;
namespace API.Helpers;
#nullable enable
public static partial class AnnotationHelper
{
private const string UiXPathScope = "//BODY/DIV[1]"; // Div[1] is the div we inject reader contents into
[GeneratedRegex("""^id\("([^"]+)"\)$""")]
private static partial Regex IdXPathRegex();
/// <summary>
/// Given an xpath that is scoped to the epub reader, transform it into a page-level xpath
/// </summary>
/// <param name="xpath"></param>
/// <returns></returns>
public static string DescopeXpath(string xpath)
{
return xpath.Replace(UiXPathScope, "//BODY").ToLowerInvariant();
}
public static void InjectSingleElementAnnotations(HtmlDocument doc, List<AnnotationDto> annotations)
{
var annotationsByElement = annotations
.GroupBy(a => a.XPath)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var (xpath, elementAnnotations) in annotationsByElement)
{
try
{
var scopedXPath = DescopeXpath(xpath);
var elem = FindElementByXPath(doc, xpath);
if (elem == null) continue;
var originalText = elem.InnerText;
// Calculate positions and sort by start position
var sortedAnnotations = elementAnnotations
.Select(a => new
{
Annotation = a,
StartPos = originalText.IndexOf(a.SelectedText, StringComparison.Ordinal)
})
.Where(a => a.StartPos >= 0)
.OrderBy(a => a.StartPos)
.ToList();
elem.RemoveAllChildren();
var currentPos = 0;
foreach (var item in sortedAnnotations)
{
// Add text before highlight
if (item.StartPos > currentPos)
{
var beforeText = originalText.Substring(currentPos, item.StartPos - currentPos);
elem.AppendChild(HtmlNode.CreateNode(beforeText));
}
// Add highlight
var highlightNode = HtmlNode.CreateNode(
$"<app-epub-highlight id=\"epub-highlight-{item.Annotation.Id}\">{item.Annotation.SelectedText}</app-epub-highlight>");
elem.AppendChild(highlightNode);
currentPos = item.StartPos + item.Annotation.SelectedText.Length;
}
// Add remaining text
if (currentPos < originalText.Length)
{
elem.AppendChild(HtmlNode.CreateNode(originalText.Substring(currentPos)));
}
}
catch (Exception)
{
/* Swallow */
}
}
}
public static void InjectMultiElementAnnotations(HtmlDocument doc, List<AnnotationDto> annotations)
{
foreach (var annotation in annotations)
{
try
{
var startXPath = DescopeXpath(annotation.XPath);
var endXPath = DescopeXpath(annotation.EndingXPath);
var startElement = FindElementByXPath(doc, startXPath);
var endElement = FindElementByXPath(doc, endXPath);
if (startElement == null || endElement == null) continue;
// Get all elements between start and end (including start and end)
var elementsInRange = GetElementsInRange(startElement, endElement);
if (elementsInRange.Count == 0) continue;
// Build full text to find our selection
var fullText = string.Join("\n\n", elementsInRange.Select(e => e.InnerText));
// Normalize both texts for comparison
var normalizedFullText = NormalizeWhitespace(fullText);
var normalizedSelectedText = NormalizeWhitespace(annotation.SelectedText);
var selectionStartPos = normalizedFullText.IndexOf(normalizedSelectedText, StringComparison.Ordinal);
if (selectionStartPos == -1) continue;
var selectionEndPos = selectionStartPos + normalizedSelectedText.Length;
// Map positions back to elements using the original (non-normalized) text
var elementTextMappings = BuildElementTextMappings(elementsInRange);
// Convert normalized positions back to original text positions
var originalSelectionStart = MapNormalizedPositionToOriginal(fullText, selectionStartPos);
var originalSelectionEnd = MapNormalizedPositionToOriginal(fullText, selectionEndPos);
// Process each element in the range
for (var i = 0; i < elementsInRange.Count; i++)
{
var element = elementsInRange[i];
var mapping = elementTextMappings[i];
var elementStart = mapping.StartPos;
var elementEnd = mapping.EndPos;
// Determine what part of this element should be highlighted
var highlightStart = Math.Max(originalSelectionStart - elementStart, 0);
var highlightEnd = Math.Min(originalSelectionEnd - elementStart, mapping.TextLength);
if (highlightEnd <= highlightStart) continue; // No highlight in this element
InjectHighlightInElement(element, highlightStart, highlightEnd, annotation.Id);
}
}
catch (Exception)
{
/* Swallow */
}
}
}
private static string NormalizeWhitespace(string text)
{
return WhitespaceRegex().Replace(text.Trim(), " ");
}
private static int MapNormalizedPositionToOriginal(string originalText, int normalizedPosition)
{
var normalizedText = NormalizeWhitespace(originalText);
if (normalizedPosition == 0) return 0;
if (normalizedPosition >= normalizedText.Length) return originalText.Length;
// Walk through both strings character by character to find the mapping
var originalPos = 0;
var normalizedPos = 0;
while (originalPos < originalText.Length && normalizedPos < normalizedPosition)
{
if (char.IsWhiteSpace(originalText[originalPos]))
{
// Skip consecutive whitespace in original
while (originalPos < originalText.Length && char.IsWhiteSpace(originalText[originalPos]))
{
originalPos++;
}
}
else
{
originalPos++;
}
// This corresponds to one space in normalized text
normalizedPos++;
}
return originalPos;
}
private static HtmlNode? FindElementByXPath(HtmlDocument doc, string xpath)
{
var idMatch = IdXPathRegex().Match(xpath);
if (!idMatch.Success) return doc.DocumentNode.SelectSingleNode(xpath.ToLowerInvariant());
var id = idMatch.Groups[1].Value;
return string.IsNullOrWhiteSpace(id) ? null : doc.GetElementbyId(id);
}
private static List<HtmlNode> GetElementsInRange(HtmlNode startElement, HtmlNode endElement)
{
var elements = new List<HtmlNode>();
var current = startElement;
elements.Add(current);
// If start and end are the same, return just that element
if (startElement == endElement) return elements;
// Traverse siblings until we reach the end element
while (current != null && current != endElement)
{
current = current.NextSibling;
if (current is {NodeType: HtmlNodeType.Element}) // Only include element nodes (skip text nodes, comments, etc.)
{
elements.Add(current);
}
}
return elements;
}
private static List<(int StartPos, int EndPos, int TextLength)> BuildElementTextMappings(List<HtmlNode> elements)
{
var mappings = new List<(int StartPos, int EndPos, int TextLength)>();
var currentPos = 0;
foreach (var element in elements)
{
var textLength = element.InnerText.Length;
mappings.Add((currentPos, currentPos + textLength, textLength));
currentPos += textLength;
}
return mappings;
}
private static void InjectHighlightInElement(HtmlNode element, int startPos, int endPos, int annotationId)
{
var originalText = element.InnerText;
element.RemoveAllChildren();
// Add text before highlight
if (startPos > 0)
{
element.AppendChild(HtmlNode.CreateNode(originalText.Substring(0, startPos)));
}
// Add highlight
var highlightText = originalText.Substring(startPos, endPos - startPos);
var highlightNode = HtmlNode.CreateNode(
$"<app-epub-highlight id=\"epub-highlight-{annotationId}\">{highlightText}</app-epub-highlight>");
element.AppendChild(highlightNode);
// Add text after highlight
if (endPos < originalText.Length)
{
element.AppendChild(HtmlNode.CreateNode(originalText.Substring(endPos)));
}
}
[GeneratedRegex(@"\s+", RegexOptions.Compiled)]
private static partial Regex WhitespaceRegex();
}

View File

@ -386,6 +386,10 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
CreateMap<AppUserAnnotation, AnnotationDto>()
.ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName))
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId));
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
}
}

View File

@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.Linq;
using API.DTOs.Reader;
namespace API.Helpers;
#nullable enable
public static class BookChapterItemHelper
{
/// <summary>
/// For a given page, finds all toc items that match the page number.
/// Returns flattened list to allow for best decision making.
/// </summary>
/// <param name="toc">The table of contents collection</param>
/// <param name="pageNum">Page number to search for</param>
/// <returns>Flattened list of all TOC items matching the page</returns>
public static IList<BookChapterItem> GetTocForPage(ICollection<BookChapterItem> toc, int pageNum)
{
var flattenedToc = FlattenToc(toc);
return flattenedToc.Where(item => item.Page == pageNum).ToList();
}
/// <summary>
/// Flattens the hierarchical table of contents into a single list.
/// Preserves all items regardless of nesting level.
/// </summary>
/// <param name="toc">The hierarchical table of contents</param>
/// <returns>Flattened list of all TOC items</returns>
public static IList<BookChapterItem> FlattenToc(ICollection<BookChapterItem> toc)
{
var result = new List<BookChapterItem>();
foreach (var item in toc)
{
result.Add(item);
if (item.Children?.Any() == true)
{
var childItems = FlattenToc(item.Children);
result.AddRange(childItems);
}
}
return result;
}
/// <summary>
/// Gets the most specific (deepest nested) TOC item for a given page.
/// Useful when you want the most granular chapter/section title.
/// </summary>
/// <param name="toc">The table of contents collection</param>
/// <param name="pageNum">Page number to search for</param>
/// <returns>The deepest nested TOC item for the page, or null if none found</returns>
public static BookChapterItem? GetMostSpecificTocForPage(ICollection<BookChapterItem> toc, int pageNum)
{
var (item, _) = GetTocItemsWithDepth(toc, pageNum, 0)
.OrderByDescending(x => x.depth)
.FirstOrDefault();
return item;
}
/// <summary>
/// Helper method that tracks depth while flattening, useful for determining hierarchy level.
/// </summary>
/// <param name="toc">Table of contents collection</param>
/// <param name="pageNum">Page number to filter by</param>
/// <param name="currentDepth">Current nesting depth</param>
/// <returns>Items with their depth information</returns>
private static IEnumerable<(BookChapterItem item, int depth)> GetTocItemsWithDepth(
ICollection<BookChapterItem> toc, int pageNum, int currentDepth)
{
foreach (var item in toc)
{
if (item.Page == pageNum)
{
yield return (item, currentDepth);
}
if (item.Children?.Any() != true) continue;
foreach (var childResult in GetTocItemsWithDepth(item.Children, pageNum, currentDepth + 1))
{
yield return childResult;
}
}
}
}

View File

@ -116,6 +116,9 @@
"generic-reading-list-create": "There was an issue creating the reading list",
"reading-list-doesnt-exist": "Reading list does not exist",
"annotation-failed-create": "Failed to create annotation, try again",
"annotation-delete": "Couldn't delete annotation",
"series-restricted": "User does not have access to this Series",
"generic-scrobble-hold": "An error occurred while adding the hold",

View File

@ -128,6 +128,7 @@ public class Program
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);
await Seed.SeedMetadataSettings(context);
await Seed.SeedDefaultHighlightSlots(unitOfWork);
}
catch (Exception ex)
{

View File

@ -7,6 +7,7 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.XPath;
using API.Data.Metadata;
using API.DTOs.Reader;
using API.Entities;
@ -14,6 +15,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
using API.Helpers;
using API.Services.Tasks.Metadata;
using Docnet.Core;
using Docnet.Core.Converters;
using Docnet.Core.Models;
@ -57,11 +59,13 @@ public interface IBookService
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath);
}
public class BookService : IBookService
public partial class BookService : IBookService
{
private readonly ILogger<BookService> _logger;
private readonly IDirectoryService _directoryService;
@ -129,38 +133,25 @@ public class BookService : IBookService
private static bool HasClickableHrefPart(HtmlNode anchor)
{
return anchor.GetAttributeValue("href", string.Empty).Contains("#")
return anchor.GetAttributeValue("href", string.Empty).Contains('#')
|| anchor.GetAttributeValue("href", string.Empty).Contains(".xhtml")
|| anchor.GetAttributeValue("href", string.Empty).Contains(".html")
&& anchor.GetAttributeValue("tabindex", string.Empty) != "-1"
&& anchor.GetAttributeValue("role", string.Empty) != "presentation";
}
public static string GetContentType(EpubContentType type)
{
string contentType;
switch (type)
var contentType = type switch
{
case EpubContentType.IMAGE_GIF:
contentType = "image/gif";
break;
case EpubContentType.IMAGE_PNG:
contentType = "image/png";
break;
case EpubContentType.IMAGE_JPEG:
contentType = "image/jpeg";
break;
case EpubContentType.FONT_OPENTYPE:
contentType = "font/otf";
break;
case EpubContentType.FONT_TRUETYPE:
contentType = "font/ttf";
break;
case EpubContentType.IMAGE_SVG:
contentType = "image/svg+xml";
break;
default:
contentType = "application/octet-stream";
break;
}
EpubContentType.IMAGE_GIF => "image/gif",
EpubContentType.IMAGE_PNG => "image/png",
EpubContentType.IMAGE_JPEG => "image/jpeg",
EpubContentType.FONT_OPENTYPE => "font/otf",
EpubContentType.FONT_TRUETYPE => "font/ttf",
EpubContentType.IMAGE_SVG => "image/svg+xml",
_ => "application/octet-stream"
};
return contentType;
}
@ -173,7 +164,7 @@ public class BookService : IBookService
// Some keys get uri encoded when parsed, so replace any of those characters with original
var mappingKey = Uri.UnescapeDataString(hrefParts[0]);
if (!mappings.ContainsKey(mappingKey))
if (!mappings.TryGetValue(mappingKey, out var mappedPage))
{
if (HasClickableHrefPart(anchor))
{
@ -188,7 +179,6 @@ public class BookService : IBookService
mappings.TryGetValue(pageKey, out currentPage);
}
anchor.Attributes.Add("kavita-page", $"{currentPage}");
anchor.Attributes.Add("kavita-part", part);
anchor.Attributes.Remove("href");
@ -203,7 +193,6 @@ public class BookService : IBookService
return;
}
var mappedPage = mappings[mappingKey];
anchor.Attributes.Add("kavita-page", $"{mappedPage}");
if (hrefParts.Length > 1)
{
@ -321,11 +310,49 @@ public class BookService : IBookService
}
}
/// <summary>
/// For each bookmark on this page, inject a specialized icon
/// </summary>
/// <param name="doc"></param>
/// <param name="ptocBookmarks"></param>
private static void InjectPTOCBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
{
if (ptocBookmarks.Count == 0) return;
foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
{
var unscopedSelector = bookmark.BookScrollId!.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
elem?.PrependChild(HtmlNode.CreateNode($"<i class='fa-solid fa-bookmark ps-1 pe-1' role='button' id='ptoc-{bookmark.Id}' title='{bookmark.Title}'></i>"));
}
}
private static void InjectAnnotations(HtmlDocument doc, List<AnnotationDto> annotations)
{
if (annotations.Count == 0) return;
var singleElementAnnotations = annotations
.Where(a => !string.IsNullOrEmpty(a.XPath) && a.XPath == a.EndingXPath)
.ToList();
var multiElementAnnotations = annotations
.Where(a => !string.IsNullOrEmpty(a.XPath) && !string.IsNullOrEmpty(a.EndingXPath) && a.XPath != a.EndingXPath)
.ToList();
AnnotationHelper.InjectSingleElementAnnotations(doc, singleElementAnnotations);
AnnotationHelper.InjectMultiElementAnnotations(doc, multiElementAnnotations);
}
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
{
var images = doc.DocumentNode.SelectNodes("//img")
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//img"));
ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//image"));
ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//svg"));
}
private static void ScopeHtmlImageCollection(EpubBookRef book, string apiBase, HtmlNodeCollection? images)
{
if (images == null) return;
var parent = images[0].ParentNode;
@ -362,6 +389,22 @@ public class BookService : IBookService
parent.AddClass("kavita-scale-width-container");
image.AddClass("kavita-scale-width");
}
}
private static void InjectImages(HtmlDocument doc, EpubBookRef book, string apiBase)
{
var images = doc.DocumentNode.SelectNodes("//img")
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
if (images == null) return;
var parent = images[0].ParentNode;
foreach (var image in images)
{
// TODO: How do I make images clickable with state?
//image.AddClass("kavita-scale-width");
}
}
@ -651,6 +694,7 @@ public class BookService : IBookService
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
{
// TODO: Refactor this to use the Async version
try
{
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
@ -871,6 +915,154 @@ public class BookService : IBookService
return dict;
}
public async Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath)
{
var ret = new Dictionary<int, int>();
try
{
using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book);
var doc = new HtmlDocument {OptionFixNestedTags = true};
var bookPages = await book.GetReadingOrderAsync();
foreach (var contentFileRef in bookPages)
{
var page = mappings[contentFileRef.Key];
var content = await contentFileRef.ReadContentAsync();
doc.LoadHtml(content);
var body = doc.DocumentNode.SelectSingleNode("//body");
if (body == null)
{
_logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("<body></body>"));
body = doc.DocumentNode.SelectSingleNode("//html/body");
}
// Find all words in the html body
// TEMP: REfactor this to use WordCountAnalyzerService
var textNodes = body!.SelectNodes("//text()[not(parent::script)]");
ret.Add(page, textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue calculating word counts per page");
return null;
}
return ret;
}
public async Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath)
{
using var book = await EpubReader.OpenBookAsync(cachedBookPath, LenientBookReaderOptions);
var counter = 0;
var doc = new HtmlDocument { OptionFixNestedTags = true };
var bookPages = await book.GetReadingOrderAsync();
foreach (var contentFileRef in bookPages)
{
if (bookmarkDto.Page != counter || contentFileRef.ContentType != EpubContentType.XHTML_1_1)
{
counter++;
continue;
}
var content = await contentFileRef.ReadContentAsync();
doc.LoadHtml(content);
var images = doc.DocumentNode.SelectNodes("//img")
?? doc.DocumentNode.SelectNodes("//image");
if (images == null || images.Count == 0)
{
throw new KavitaException("No images found on the specified page");
}
if (bookmarkDto.ImageOffset >= images.Count)
{
throw new KavitaException($"Image index {bookmarkDto.ImageOffset} is out of range. Page has {images.Count} images");
}
var targetImage = images[bookmarkDto.ImageOffset];
// Get the image source attribute
string? srcAttributeName = null;
if (targetImage.Attributes["src"] != null)
{
srcAttributeName = "src";
}
else if (targetImage.Attributes["xlink:href"] != null)
{
srcAttributeName = "xlink:href";
}
if (string.IsNullOrEmpty(srcAttributeName))
{
throw new KavitaException("Image element does not have a valid source attribute");
}
var imageSource = targetImage.Attributes[srcAttributeName].Value;
// Clean and get the correct key for the image
var imageKey = CleanContentKeys(GetKeyForImage(book, imageSource));
// Check if it's an external URL
if (imageKey.StartsWith("http"))
{
throw new KavitaException("Cannot copy external images");
}
// Get the image file from the epub
if (!book.Content.Images.TryGetLocalFileRefByKey(imageKey, out var imageFile))
{
throw new KavitaException($"Image file not found in epub: {imageKey}");
}
// Read the image content
var imageContent = await imageFile.ReadContentAsBytesAsync();
// Determine file extension from the image key or content type
var extension = Path.GetExtension(imageKey);
if (string.IsNullOrEmpty(extension))
{
// Fallback to determining extension from content type
extension = imageFile.ContentType switch
{
EpubContentType.IMAGE_JPEG => ".jpg",
EpubContentType.IMAGE_PNG => ".png",
EpubContentType.IMAGE_GIF => ".gif",
EpubContentType.IMAGE_SVG => ".svg",
_ => ".png"
};
}
// Create temp directory for this chapter if it doesn't exist
var tempChapterDir = Path.Combine(_directoryService.TempDirectory, chapterId.ToString());
_directoryService.ExistOrCreate(tempChapterDir);
// Generate unique filename
var uniqueFilename = $"{Guid.NewGuid()}{extension}";
var tempFilePath = Path.Combine(tempChapterDir, uniqueFilename);
// Write the image to the temp file
await File.WriteAllBytesAsync(tempFilePath, imageContent);
return tempFilePath;
}
throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
}
/// <summary>
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
/// then null is returned. This expects only an epub file
@ -1014,15 +1206,27 @@ public class BookService : IBookService
/// <param name="body">Body element from the epub</param>
/// <param name="mappings">Epub mappings</param>
/// <param name="page">Page number we are loading</param>
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
/// <returns></returns>
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page)
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
{
await InlineStyles(doc, book, apiBase, body);
RewriteAnchors(page, doc, mappings);
// TODO: Pass bookmarks here for state management
ScopeImages(doc, book, apiBase);
InjectImages(doc, book, apiBase);
// Inject PTOC Bookmark Icons
InjectPTOCBookmarks(doc, ptocBookmarks);
// Inject Annotations
InjectAnnotations(doc, annotations);
return PrepareFinalHtml(doc, body);
}
@ -1097,53 +1301,29 @@ public class BookService : IBookService
{
foreach (var navigationItem in navItems)
{
if (navigationItem.NestedItems.Count == 0)
var tocItem = CreateToCChapter(book, navigationItem, mappings);
if (tocItem != null)
{
CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
continue;
chaptersList.Add(tocItem);
}
var nestedChapters = new List<BookChapterItem>();
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
{
var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
if (mappings.TryGetValue(key, out var mapping))
{
nestedChapters.Add(new BookChapterItem
{
Title = nestedChapter.Title,
Page = mapping,
Part = nestedChapter.Link?.Anchor ?? string.Empty,
Children = new List<BookChapterItem>()
});
}
}
CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
}
}
if (chaptersList.Count != 0) return chaptersList;
// Rest of your fallback logic remains the same...
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
var tocPage = book.Content.Html.Local.Select(s => s.Key)
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
var content = await file.ReadContentAsync();
var doc = new HtmlDocument();
doc.LoadHtml(content);
// TODO: We may want to check if there is a toc.ncs file to better handle nested toc
// We could do a fallback first with ol/lis
var anchors = doc.DocumentNode.SelectNodes("//a");
if (anchors == null) return chaptersList;
@ -1164,19 +1344,56 @@ public class BookService : IBookService
Title = anchor.InnerText,
Page = mappings[key],
Part = part,
Children = new List<BookChapterItem>()
Children = []
});
}
return chaptersList;
}
private static int CountParentDirectory(string path)
private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
{
const string pattern = @"\.\./";
var matches = Regex.Matches(path, pattern);
// Get the page mapping for the current navigation item
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
int? page = null;
if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping))
{
page = mapping;
}
return matches.Count;
// Recursively process nested items
var children = new List<BookChapterItem>();
if (navigationItem.NestedItems?.Count > 0)
{
foreach (var nestedItem in navigationItem.NestedItems)
{
var childItem = CreateToCChapter(book, nestedItem, mappings);
if (childItem != null)
{
children.Add(childItem);
}
}
}
// Only create a BookChapterItem if we have a valid page or children
if (page.HasValue || children.Count > 0)
{
return new BookChapterItem
{
Title = navigationItem.Title ?? string.Empty,
Page = page ?? 0, // You might want to handle this differently
Part = navigationItem.Link?.Anchor ?? string.Empty,
Children = children
};
}
return null;
}
private static int CountParentDirectory(string? path)
{
if (string.IsNullOrEmpty(path)) return 0;
return ParentDirectoryRegex().Matches(path).Count;
}
/// <summary>
@ -1213,7 +1430,8 @@ public class BookService : IBookService
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
/// <exception cref="KavitaException">All exceptions throw this</exception>
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl)
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl,
List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
{
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book);
@ -1255,7 +1473,7 @@ public class BookService : IBookService
body = doc.DocumentNode.SelectSingleNode("/html/body");
}
return await ScopePage(doc, book, apiBase, body, mappings, page);
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations);
}
} catch (Exception ex)
{
@ -1267,37 +1485,37 @@ public class BookService : IBookService
throw new KavitaException("epub-html-missing");
}
private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
{
if (navigationItem.Link == null)
{
var item = new BookChapterItem
{
Title = navigationItem.Title,
Children = nestedChapters
};
if (nestedChapters.Count > 0)
{
item.Page = nestedChapters[0].Page;
}
chaptersList.Add(item);
}
else
{
var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
if (mappings.ContainsKey(groupKey))
{
chaptersList.Add(new BookChapterItem
{
Title = navigationItem.Title,
Page = mappings[groupKey],
Children = nestedChapters
});
}
}
}
// private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
// ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
// {
// if (navigationItem.Link == null)
// {
// var item = new BookChapterItem
// {
// Title = navigationItem.Title,
// Children = nestedChapters
// };
// if (nestedChapters.Count > 0)
// {
// item.Page = nestedChapters[0].Page;
// }
//
// chaptersList.Add(item);
// }
// else
// {
// var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
// if (mappings.ContainsKey(groupKey))
// {
// chaptersList.Add(new BookChapterItem
// {
// Title = navigationItem.Title,
// Page = mappings[groupKey],
// Children = nestedChapters
// });
// }
// }
// }
/// <summary>
@ -1341,6 +1559,28 @@ public class BookService : IBookService
return string.Empty;
}
public static string? GetChapterTitleFromToC(ICollection<BookChapterItem>? tableOfContents, int pageNumber)
{
if (tableOfContents == null) return null;
foreach (var item in tableOfContents)
{
// Check if current item matches the page number
if (item.Page == pageNumber)
return item.Title;
// Recursively search children if they exist
if (item.Children?.Count > 0)
{
var childResult = GetChapterTitleFromToC(item.Children, pageNumber);
if (childResult != null)
return childResult;
}
}
return null;
}
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
@ -1430,4 +1670,7 @@ public class BookService : IBookService
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
}
}
[GeneratedRegex(@"\.\./")]
private static partial Regex ParentDirectoryRegex();
}

View File

@ -9,6 +9,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using Hangfire;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService
/// <param name="bookmarkDto"></param>
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
/// <returns>If the save to DB and copy was successful</returns>
public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
public async Task<bool> BookmarkPage(AppUser? userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
{
if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false;
if (userWithBookmarks?.Bookmarks == null)
{
throw new KavitaException("Bookmarks cannot be null!");
}
try
{
var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId);
var userBookmark = userWithBookmarks.Bookmarks
.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId && b.ImageOffset == bookmarkDto.ImageOffset);
if (userBookmark != null)
{
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
@ -137,6 +143,9 @@ public class BookmarkService : IBookmarkService
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId,
FileName = Path.Join(targetFolderStem, fileInfo.Name),
ImageOffset = bookmarkDto.ImageOffset,
XPath = bookmarkDto.XPath,
ChapterTitle = bookmarkDto.ChapterTitle,
AppUserId = userWithBookmarks.Id
};
@ -170,7 +179,7 @@ public class BookmarkService : IBookmarkService
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
{
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page && x.ImageOffset == bookmarkDto.ImageOffset);
try
{
if (bookmarkToDelete != null)

View File

@ -41,6 +41,7 @@ public interface ICacheService
IEnumerable<FileDimensionDto> GetCachedFileDimensions(string cachePath);
string GetCachedBookmarkPagePath(int seriesId, int page);
string GetCachedFile(Chapter chapter);
string GetCachedFile(int chapterId, string firstFilePath);
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files, bool extractPdfImages = false);
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
void CleanupBookmarkCache(int seriesId);
@ -155,6 +156,17 @@ public class CacheService : ICacheService
return path;
}
public string GetCachedFile(int chapterId, string firstFilePath)
{
var extractPath = GetCachePath(chapterId);
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(firstFilePath));
if (!(_directoryService.FileSystem.FileInfo.New(path).Exists))
{
path = firstFilePath;
}
return path;
}
/// <summary>
/// Caches the files for the given chapter to CacheDirectory
@ -342,9 +354,7 @@ public class CacheService : ICacheService
// Calculate what chapter the page belongs to
var path = GetCachePath(chapterId);
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
//.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
.ToArray();
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions);
return GetPageFromFiles(files, page);
}

View File

@ -423,11 +423,7 @@ public class EmailService : IEmailService
smtpClient.Timeout = 20000;
var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None;
await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl);
if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password))
{
await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password);
}
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
@ -445,6 +441,12 @@ public class EmailService : IEmailService
try
{
await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl);
if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password))
{
await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password);
}
await smtpClient.SendAsync(email);
if (user != null)
{

View File

@ -23,6 +23,8 @@ public interface ILocalizationService
public class LocalizationService : ILocalizationService
{
private const string LocaleCacheKey = "locales";
private readonly IDirectoryService _directoryService;
private readonly IMemoryCache _cache;
private readonly IUnitOfWork _unitOfWork;
@ -33,6 +35,7 @@ public class LocalizationService : ILocalizationService
private readonly string _localizationDirectoryUi;
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly MemoryCacheEntryOptions _localsCacheOptions;
public LocalizationService(IDirectoryService directoryService,
@ -62,6 +65,10 @@ public class LocalizationService : ILocalizationService
_cacheOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
_localsCacheOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromHours(24));
}
/// <summary>
@ -139,6 +146,11 @@ public class LocalizationService : ILocalizationService
/// <returns></returns>
public IEnumerable<KavitaLocale> GetLocales()
{
if (_cache.TryGetValue(LocaleCacheKey, out List<KavitaLocale>? cachedLocales) && cachedLocales != null)
{
return cachedLocales;
}
var uiLanguages = _directoryService
.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json");
var backendLanguages = _directoryService
@ -246,7 +258,10 @@ public class LocalizationService : ILocalizationService
}
}
return locales.Values;
var kavitaLocales = locales.Values.ToList();
_cache.Set(LocaleCacheKey, kavitaLocales, _localsCacheOptions);
return kavitaLocales;
}
// Helper methods that would need to be implemented

View File

@ -356,7 +356,7 @@ public class ReaderService : IReaderService
return page;
}
private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
private static int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
{
if (volume.IsSpecial())
{

View File

@ -35,7 +35,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private readonly IReaderService _readerService;
private readonly IMediaErrorService _mediaErrorService;
private const int AverageCharactersPerWord = 5;
public const int AverageCharactersPerWord = 5;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
@ -247,7 +247,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
_unitOfWork.MangaFileRepository.Update(file);
}
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
{
try
@ -256,7 +255,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
doc.LoadHtml(await bookFile.ReadContentAsync());
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)
{
@ -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,4 +1,5 @@
using System;
using API.DTOs.Reader;
using API.DTOs.Update;
using API.Entities.Person;
using API.Extensions;
@ -156,6 +157,12 @@ public static class MessageFactory
/// A Rate limit error was hit when matching a series with Kavita+
/// </summary>
public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError";
/// <summary>
/// Annotation is updated within the reader
/// </summary>
public const string AnnotationUpdate = "AnnotationUpdate";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
@ -683,6 +690,7 @@ public static class MessageFactory
},
};
}
public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName)
{
return new SignalRMessage()
@ -690,8 +698,20 @@ public static class MessageFactory
Name = ExternalMatchRateLimitError,
Body = new
{
seriesId = seriesId,
seriesName = seriesName,
seriesId,
seriesName,
},
};
}
public static SignalRMessage AnnotationUpdateEvent(AnnotationDto dto)
{
return new SignalRMessage()
{
Name = AnnotationUpdate,
Body = new
{
Annotation = dto
},
};
}

View File

@ -298,6 +298,7 @@ public class Startup
// v0.8.8
await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger);
await ManualMigrateBookReadingProgress.Migrate(dataContext, unitOfWork, logger);
#endregion
@ -421,6 +422,11 @@ public class Startup
opts.IncludeQueryInRequestPath = true;
});
if (Configuration.AllowIFraming)
{
logger.LogCritical("appsetting.json has allow iframing on! This may allow for clickjacking on the server. User beware");
}
app.Use(async (context, next) =>
{
context.Response.Headers[HeaderNames.Vary] =
@ -433,11 +439,7 @@ public class Startup
context.Response.Headers.XFrameOptions = "SAMEORIGIN";
// Setup CSP to ensure we load assets only from these origins
context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';");
}
else
{
logger.LogCritical("appsetting.json has allow iframing on! This may allow for clickjacking on the server. User beware");
context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none';";
}
await next();

View File

@ -1,8 +1,15 @@
{
"TokenKey": "super secret unguessable key that is longer because we require it",
"Port": 5000,
"IpAddresses": "",
"IpAddresses": "",
"BaseUrl": "/",
"Cache": 75,
"AllowIFraming": false
"AllowIFraming": false,
"OpenIdConnectSettings": {
"Authority": "",
"ClientId": "kavita",
"Secret": "",
"CustomScopes": [],
"Enabled": false
}
}

View File

@ -20,7 +20,7 @@ public static class Configuration
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020
? "http://localhost:5020" : "https://plus.kavitareader.com";
public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
public static int Port

View File

@ -36,4 +36,4 @@ Run `npm run start`
- all components must be standalone
# Update latest angular
`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular-devkit/build-angular @angular/cdk`
`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular/cdk @angular/animations @angular/common @angular/forms @angular/platform-browser @angular/platform-browser-dynamic @angular/router`

View File

@ -62,7 +62,8 @@
"stylePreprocessorOptions": {
"sass": {
"silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"]
}
},
"includePaths": ["src"]
}
},
"configurations": {
@ -81,8 +82,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
"maximumWarning": "3mb",
"maximumError": "4mb"
},
{
"type": "anyComponentStyle",
@ -125,5 +126,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

4767
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"ng": "ng",
"start": "npm run cache-locale && ng serve --host 0.0.0.0",
"build": "npm run cache-locale && ng build",
"build-backend": "ng build && rm -r ../../API/wwwroot/* && cp -r dist/browser/* ../../API/wwwroot",
"minify-langs": "node minify-json.js",
"cache-locale": "node hash-localization.js",
"cache-locale-prime": "node hash-localization-prime.js",
@ -16,70 +17,72 @@
},
"private": true,
"dependencies": {
"@angular-slider/ngx-slider": "^19.0.0",
"@angular/animations": "^19.2.5",
"@angular/cdk": "^19.2.8",
"@angular/common": "^19.2.5",
"@angular/compiler": "^19.2.5",
"@angular/core": "^19.2.5",
"@angular/forms": "^19.2.5",
"@angular/localize": "^19.2.5",
"@angular/platform-browser": "^19.2.5",
"@angular/platform-browser-dynamic": "^19.2.5",
"@angular/router": "^19.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"@angular-slider/ngx-slider": "^20.0.0",
"@angular/animations": "^20.1.4",
"@angular/cdk": "^20.1.4",
"@angular/common": "^20.1.4",
"@angular/compiler": "^20.1.4",
"@angular/core": "^20.1.4",
"@angular/forms": "^20.1.4",
"@angular/localize": "^20.1.4",
"@angular/platform-browser": "^20.1.4",
"@angular/platform-browser-dynamic": "^20.1.4",
"@angular/router": "^20.1.4",
"@fortawesome/fontawesome-free": "^7.0.0",
"@iharbeck/ngx-virtual-scroller": "^19.0.1",
"@iplab/ngx-file-upload": "^19.0.3",
"@iplab/ngx-color-picker": "^20.0.0",
"@iplab/ngx-file-upload": "^20.0.0",
"@jsverse/transloco": "^7.6.1",
"@jsverse/transloco-locale": "^7.0.1",
"@jsverse/transloco-persist-lang": "^7.0.2",
"@jsverse/transloco-persist-translations": "^7.0.1",
"@jsverse/transloco-preload-langs": "^7.0.1",
"@microsoft/signalr": "^8.0.7",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@microsoft/signalr": "^9.0.6",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@popperjs/core": "^2.11.7",
"@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
"@swimlane/ngx-charts": "^23.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0",
"bootstrap": "^5.3.2",
"charts.css": "^1.1.0",
"charts.css": "^1.2.0",
"file-saver": "^2.0.5",
"luxon": "^3.6.1",
"luxon": "^3.7.1",
"ng-circle-progress": "^1.7.1",
"ng-lazyload-image": "^9.1.3",
"ng-select2-component": "^17.2.4",
"ngx-color-picker": "^19.0.0",
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
"ngx-extended-pdf-viewer": "^24.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-quill": "^28.0.1",
"ngx-stars": "^1.6.5",
"ngx-toastr": "^19.0.0",
"nosleep.js": "^0.12.0",
"rxjs": "^7.8.2",
"screenfull": "^6.0.2",
"swiper": "^8.4.6",
"swiper": "^11.2.10",
"tslib": "^2.8.1",
"zone.js": "^0.15.0"
"zone.js": "^0.15.1"
},
"devDependencies": {
"@angular-eslint/builder": "^19.3.0",
"@angular-eslint/eslint-plugin": "^19.3.0",
"@angular-eslint/eslint-plugin-template": "^19.3.0",
"@angular-eslint/schematics": "^19.3.0",
"@angular-eslint/template-parser": "^19.3.0",
"@angular/build": "^19.2.6",
"@angular/cli": "^19.2.6",
"@angular/compiler-cli": "^19.2.5",
"@angular-eslint/builder": "^20.1.1",
"@angular-eslint/eslint-plugin": "^20.1.1",
"@angular-eslint/eslint-plugin-template": "^20.1.1",
"@angular-eslint/schematics": "^20.1.1",
"@angular-eslint/template-parser": "^20.1.1",
"@angular/build": "^20.1.4",
"@angular/cli": "^20.1.4",
"@angular/compiler-cli": "^20.1.4",
"@types/d3": "^7.4.3",
"@types/file-saver": "^2.0.7",
"@types/luxon": "^3.6.2",
"@types/node": "^22.13.13",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"eslint": "^9.23.0",
"@types/marked": "^5.0.2",
"@types/node": "^24.0.14",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.31.0",
"jsonminify": "^0.4.2",
"karma-coverage": "~2.2.0",
"ts-node": "~10.9.1",
"typescript": "^5.5.4",
"typescript": "^5.8.3",
"webpack-bundle-analyzer": "^4.10.2"
}
}

View File

@ -0,0 +1,37 @@
@mixin quill-white-theme {
:host ::ng-deep .ql-snow {
.ql-stroke {
stroke: white;
}
.ql-fill {
fill: white;
}
.ql-picker {
color: white;
}
.ql-editor.ql-blank::before {
color: white;
}
// This doesn't work
.ql-toolbar {
*:hover, *:focus, ql-toolbar button:hover {
color: var(--primary-color);
}
.ql-picker-item.ql-selected, .ql-toolbar .ql-picker-label.ql-active {
color: var(--primary-color);
}
}
}
//.ql-snow.ql-toolbar button:hover, .ql-snow .ql-toolbar button:hover, .ql-snow.ql-toolbar button:focus,
//.ql-snow .ql-toolbar button:focus, .ql-snow.ql-toolbar button.ql-active, .ql-snow .ql-toolbar button.ql-active,
//.ql-snow.ql-toolbar .ql-picker-label:hover, .ql-snow .ql-toolbar .ql-picker-label:hover, .ql-snow.ql-toolbar
//.ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-item:hover,
//.ql-snow .ql-toolbar .ql-picker-item:hover, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar
//.ql-picker-item.ql-selected {
// color: var(--primary-color);
//}
}

View File

@ -0,0 +1,47 @@
import {Directive, ElementRef, EventEmitter, inject, input, OnDestroy, Output} from '@angular/core';
import {fromEvent, merge, Subscription, switchMap, tap, timer} from "rxjs";
import {takeUntil} from "rxjs/operators";
@Directive({
selector: '[appLongClick]',
standalone: true
})
export class LongClickDirective implements OnDestroy {
private elementRef: ElementRef = inject(ElementRef);
private readonly eventSubscribe: Subscription;
/**
* How long should the element be pressed for
* @default 500
*/
threshold = input(500);
@Output() longClick = new EventEmitter();
constructor() {
const start$ = merge(
fromEvent(this.elementRef.nativeElement, 'touchstart'),
fromEvent(this.elementRef.nativeElement, 'mousedown')
);
const end$ = merge(
fromEvent(this.elementRef.nativeElement, 'touchend'),
fromEvent(this.elementRef.nativeElement, 'mouseup')
);
this.eventSubscribe = start$
.pipe(
switchMap(() => timer(this.threshold()).pipe(takeUntil(end$))),
tap(() => this.longClick.emit())
).subscribe();
}
ngOnDestroy(): void {
if (this.eventSubscribe) {
this.eventSubscribe.unsubscribe();
}
}
}

View File

@ -4,5 +4,4 @@ export interface IHasReadingTime {
avgHoursToRead: number;
pages: number;
wordCount: number;
}

View File

@ -0,0 +1,5 @@
import {Annotation} from "../../book-reader/_models/annotations/annotation";
export interface AnnotationUpdateEvent {
annotation: Annotation;
}

View File

@ -1,6 +1,6 @@
import { FileDimension } from "src/app/manga-reader/_models/file-dimension";
import { LibraryType } from "../library/library";
import { MangaFormat } from "../manga-format";
import {FileDimension} from "src/app/manga-reader/_models/file-dimension";
import {LibraryType} from "../library/library";
import {MangaFormat} from "../manga-format";
export interface BookmarkInfo {
seriesName: string;

View File

@ -1,5 +1,6 @@
import {PageLayoutMode} from '../page-layout-mode';
import {SiteTheme} from './site-theme';
import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot";
export interface Preferences {
@ -12,6 +13,7 @@ export interface Preferences {
collapseSeriesRelationships: boolean;
shareReviews: boolean;
locale: string;
bookReaderHighlightSlots: HighlightSlot[];
// Kavita+
aniListScrobblingEnabled: boolean;

View File

@ -1,10 +1,25 @@
import {Series} from "../series";
export interface PageBookmark {
id: number;
page: number;
seriesId: number;
volumeId: number;
chapterId: number;
series: Series;
id: number;
page: number;
seriesId: number;
volumeId: number;
chapterId: number;
/**
* Only present on epub-based Bookmarks
*/
imageOffset: number;
/**
* Only present on epub-based Bookmarks
*/
xPath: string | null;
/**
* This is only used when getting all bookmarks.
*/
series: Series | null;
/**
* Chapter name (from ToC) or Title (from ComicInfo/PDF)
*/
chapterTitle: string | null;
}

View File

@ -3,6 +3,9 @@ export interface PersonalToC {
pageNumber: number;
title: string;
bookScrollId: string | undefined;
selectedText: string | null;
chapterTitle: string | null;
/* Ui Only */
position: 0;
}

View File

@ -1,36 +1,39 @@
import { Chapter } from "../chapter";
import { Library } from "../library/library";
import { MangaFile } from "../manga-file";
import { SearchResult } from "./search-result";
import { Tag } from "../tag";
import {Chapter} from "../chapter";
import {Library} from "../library/library";
import {MangaFile} from "../manga-file";
import {SearchResult} from "./search-result";
import {Tag} from "../tag";
import {BookmarkSearchResult} from "./bookmark-search-result";
import {Genre} from "../metadata/genre";
import {ReadingList} from "../reading-list";
import {UserCollection} from "../collection-tag";
import {Person} from "../metadata/person";
import {Annotation} from "../../book-reader/_models/annotations/annotation";
export class SearchResultGroup {
libraries: Array<Library> = [];
series: Array<SearchResult> = [];
collections: Array<UserCollection> = [];
readingLists: Array<ReadingList> = [];
persons: Array<Person> = [];
genres: Array<Genre> = [];
tags: Array<Tag> = [];
files: Array<MangaFile> = [];
chapters: Array<Chapter> = [];
bookmarks: Array<BookmarkSearchResult> = [];
libraries: Array<Library> = [];
series: Array<SearchResult> = [];
collections: Array<UserCollection> = [];
readingLists: Array<ReadingList> = [];
persons: Array<Person> = [];
genres: Array<Genre> = [];
tags: Array<Tag> = [];
files: Array<MangaFile> = [];
chapters: Array<Chapter> = [];
bookmarks: Array<BookmarkSearchResult> = [];
annotations: Array<Annotation> = [];
reset() {
this.libraries = [];
this.series = [];
this.collections = [];
this.readingLists = [];
this.persons = [];
this.genres = [];
this.tags = [];
this.files = [];
this.chapters = [];
this.bookmarks = [];
}
reset() {
this.libraries = [];
this.series = [];
this.collections = [];
this.readingLists = [];
this.persons = [];
this.genres = [];
this.tags = [];
this.files = [];
this.chapters = [];
this.bookmarks = [];
this.annotations = [];
}
}

View File

@ -0,0 +1,18 @@
import {Pipe, PipeTransform} from '@angular/core';
import {HighlightColor} from "../book-reader/_models/annotations/annotation";
@Pipe({
name: 'highlightColor'
})
export class HighlightColorPipe implements PipeTransform {
transform(value: HighlightColor): string {
switch (value) {
case HighlightColor.Blue:
return 'blue';
case HighlightColor.Green:
return 'green';
}
}
}

View File

@ -0,0 +1,21 @@
import {Pipe, PipeTransform} from '@angular/core';
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {translate} from "@jsverse/transloco";
/**
* Responsible to create a --Page X, Chapter Y
*/
@Pipe({
name: 'pageChapterLabel'
})
export class PageChapterLabelPipe implements PipeTransform {
transform(annotation: Annotation): string {
const pageNumber = annotation.pageNumber;
const chapterTitle = annotation.chapterTitle ?? '';
if (chapterTitle === '') return translate('page-chapter-label-pipe.page-only', {pageNumber});
return translate('page-chapter-label-pipe.full', {pageNumber, chapterTitle});
}
}

View File

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

View File

@ -0,0 +1,14 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
@Pipe({
name: 'slotColor'
})
@Injectable({ providedIn: 'root' })
export class SlotColorPipe implements PipeTransform {
transform(value: RgbaColor) {
return `rgba(${value.r}, ${value.g},${value.b}, ${value.a})`;
}
}

View File

@ -0,0 +1,132 @@
import {computed, inject, Injectable, signal} from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {Annotation} from '../book-reader/_models/annotations/annotation';
import {TextResonse} from "../_types/text-response";
import {map, of, tap} from "rxjs";
import {switchMap} from "rxjs/operators";
import {AccountService} from "./account.service";
import {User} from "../_models/user";
import {MessageHubService} from "./message-hub.service";
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
import {Router} from "@angular/router";
/**
* Represents any modification (create/delete/edit) that occurs to annotations
*/
export interface AnnotationEvent {
pageNumber: number;
type: 'create' | 'delete' | 'edit';
annotation: Annotation;
}
@Injectable({
providedIn: 'root'
})
export class AnnotationService {
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
private readonly messageHub = inject(MessageHubService);
private readonly router = inject(Router);
private readonly baseUrl = environment.apiUrl;
private _annotations = signal<Annotation[]>([]);
/**
* Annotations for a given book
*/
public readonly annotations = this._annotations.asReadonly();
private _events = signal<AnnotationEvent | null>(null);
public readonly events = this._events.asReadonly();
private readonly user = signal<User | null>(null);
public readonly slots = computed(() => {
const currentUser = this.user();
return currentUser?.preferences?.bookReaderHighlightSlots ?? [];
});
constructor() {
this.accountService.currentUser$.subscribe(user => {
this.user.set(user!);
});
}
updateSlotColor(index: number, color: RgbaColor) {
const user = this.accountService.currentUserSignal();
if (!user) return of([]);
const preferences = user.preferences;
preferences.bookReaderHighlightSlots[index].color = color;
return this.accountService.updatePreferences(preferences).pipe(
map((p) => p.bookReaderHighlightSlots)
);
}
getAllAnnotations(chapterId: number) {
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'annotation/all?chapterId=' + chapterId).pipe(map(annotations => {
this._annotations.set(annotations);
return annotations;
}));
}
createAnnotation(data: Annotation) {
return this.httpClient.post<Annotation>(this.baseUrl + 'annotation/create', data).pipe(
tap(newAnnotation => {
this._events.set({
pageNumber: newAnnotation.pageNumber,
type: 'create',
annotation: newAnnotation
});
}),
switchMap(newAnnotation => this.getAllAnnotations(newAnnotation.chapterId))
);
}
updateAnnotation(data: Annotation) {
return this.httpClient.post<Annotation>(this.baseUrl + 'annotation/update', data).pipe(
switchMap(newAnnotation => this.getAllAnnotations(data.chapterId)),
tap(_ => {
console.log('emitting edit event');
this._events.set({
pageNumber: data.pageNumber,
type: 'edit',
annotation: data
});
}),
);
}
getAnnotation(annotationId: number) {
return this.httpClient.get<Annotation>(this.baseUrl + `annotation/${annotationId}`);
}
delete(id: number) {
const filtered = this.annotations().filter(a => a.id === id);
if (filtered.length === 0) return of();
const annotationToDelete = filtered[0];
return this.httpClient.delete(this.baseUrl + `annotation?annotationId=${id}`, TextResonse).pipe(tap(_ => {
const annotations = this._annotations();
this._annotations.set(annotations.filter(a => a.id !== id));
this._events.set({
pageNumber: annotationToDelete.pageNumber,
type: 'delete',
annotation: annotationToDelete
});
}));
}
/**
* Routes to the book reader with the annotation in view
* @param item
*/
navigateToAnnotation(item: Annotation) {
this.router.navigate(['/library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], { queryParams: { annotation: item.id } });
}
}

View File

@ -1,7 +1,8 @@
import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {inject, Injectable} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {BehaviorSubject, filter, take, tap, timer} from 'rxjs';
import {NavigationEnd, Router} from "@angular/router";
import {environment} from "../../environments/environment";
interface ColorSpace {
primary: string;
@ -30,6 +31,9 @@ const colorScapeSelector = 'colorscape';
providedIn: 'root'
})
export class ColorscapeService {
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null);
public readonly colors$ = this.colorSubject.asObservable();
@ -37,8 +41,12 @@ export class ColorscapeService {
private minDuration = 1000; // minimum duration
private maxDuration = 4000; // maximum duration
private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace
// Use 0.179 as threshold (roughly equivalent to #767676)
// This gives better visual results than 0.5
public static readonly defaultLuminanceThreshold = 0.179;
constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) {
constructor() {
this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
tap(() => this.checkAndResetColorscapeAfterDelay())
@ -46,6 +54,100 @@ export class ColorscapeService {
}
/**
* Returns a fitting text color depending on the background color of the element
* style.backgroundColor **must** be set on the passed element for this to work
* @param el
*/
getContrastingTextColor(el: HTMLElement): string {
const style = window.getComputedStyle(el);
const bgColor = style.backgroundColor;
if (bgColor === '') {
return 'black';
}
const rgba = this.rgbStringToRGBA(bgColor);
const luminance = this.getLuminance(rgba);
return luminance > ColorscapeService.defaultLuminanceThreshold ? 'black' : 'white';
}
getLuminance(rgba: RGBAColor): number {
// Convert RGB to relative luminance with gamma correction
const getRelativeLuminance = (color: number): number => {
const c = color / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const r = getRelativeLuminance(rgba.r);
const g = getRelativeLuminance(rgba.g);
const b = getRelativeLuminance(rgba.b);
// WCAG relative luminance formula (https://www.w3.org/WAI/GL/wiki/Relative_luminance)
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Generates a base64 encoding for an Image. Used in manual file upload flow.
* @param img
* @returns
*/
getBase64Image(img: HTMLImageElement) {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d", {alpha: false});
if (!ctx) {
return '';
}
ctx.drawImage(img, 0, 0);
return canvas.toDataURL("image/png");
}
getAverageColour(img: HTMLImageElement, sampleWidth?: number, sampleHeight?: number): RGBAColor | undefined {
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const sw = sampleWidth ?? 20;
const sh = sampleHeight ?? 20;
let imageData: ImageData;
try {
imageData = ctx.getImageData(
canvas.width -sw,
canvas.height - sh,
sw,
sh
);
} catch (e) {
return;
}
let r = 0, g = 0, b = 0;
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
r += pixels[i];
g += pixels[i + 1];
b += pixels[i + 2];
}
const pixelCount = pixels.length / 4;
return {
r: Math.round(r / pixelCount),
g: Math.round(g / pixelCount),
b: Math.round(b / pixelCount),
a: 0.5,
};
}
/**
* Due to changing ColorScape on route end, we might go from one space to another, but the router events resets to default
* This delays it to see if the colors changed or not in 500ms and if not, then we will reset to default.
@ -153,7 +255,7 @@ export class ColorscapeService {
}
}
private hexToRGBA(hex: string, opacity: number = 1): RGBAColor {
public hexToRGBA(hex: string, opacity: number = 1): RGBAColor {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {

View File

@ -0,0 +1,53 @@
import {inject, Injectable, ViewContainerRef} from '@angular/core';
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {EpubHighlightComponent} from "../book-reader/_components/_annotations/epub-highlight/epub-highlight.component";
import {DOCUMENT} from "@angular/common";
@Injectable({
providedIn: 'root'
})
export class EpubHighlightService {
private readonly document = inject(DOCUMENT);
initializeHighlightElements(annotations: Annotation[], container: ViewContainerRef, selectFromElement?: Element | null | undefined,
configOptions: {showHighlight: boolean, showIcon: boolean} | null = null) {
const annotationsMap: {[key: number]: Annotation} = annotations.reduce((map, obj) => {
// @ts-ignore
map[obj.id] = obj;
return map;
}, {});
// Make the highlight components "real"
const selector = selectFromElement ?? this.document;
const highlightElems = selector.querySelectorAll('app-epub-highlight');
for (let i = 0; i < highlightElems.length; i++) {
const highlight = highlightElems[i];
const idAttr = highlight.getAttribute('id');
// Don't allow highlight injection unless the id is present
if (!idAttr) continue;
const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
const componentRef = container.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
{
projectableNodes: [
[document.createTextNode(highlight.innerHTML)]
]
});
if (highlight.parentNode != null) {
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
}
componentRef.setInput('annotation', annotationsMap[annotationId]);
if (configOptions != null) {
componentRef.setInput('showHighlight', configOptions.showHighlight);
}
}
}
}

View File

@ -0,0 +1,172 @@
import {inject, Injectable, signal} from '@angular/core';
import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {
ViewAnnotationsDrawerComponent
} from "../book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component";
import {
LoadPageEvent,
ViewBookmarkDrawerComponent
} from "../book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
import {ViewTocDrawerComponent} from "../book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component";
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {
EpubSettingDrawerComponent,
} from "../book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component";
import {ReadingProfile} from "../_models/preferences/reading-profiles";
import {PageBookmark} from "../_models/readers/page-bookmark";
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {
AnnotationMode,
ViewEditAnnotationDrawerComponent
} from "../book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component";
import {AccountService} from "./account.service";
import {EpubReaderSettingsService} from './epub-reader-settings.service';
/**
* Responsible for opening the different readers and providing any context needed. Handles closing or keeping a stack of menus open.
*/
@Injectable({
providedIn: 'root'
})
export class EpubReaderMenuService {
private readonly offcanvasService = inject(NgbOffcanvas);
private readonly utilityService = inject(UtilityService);
private readonly accountService = inject(AccountService);
/**
* The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded
*/
public readonly isDrawerOpen = signal<boolean>(false);
openCreateAnnotationDrawer(annotation: Annotation, callbackFn: () => void) {
const ref = this.offcanvasService.open(ViewEditAnnotationDrawerComponent, {position: 'bottom'});
ref.closed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
ref.dismissed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
(ref.componentInstance as ViewEditAnnotationDrawerComponent).annotation.set(annotation);
(ref.componentInstance as ViewEditAnnotationDrawerComponent).mode.set(AnnotationMode.Create);
this.isDrawerOpen.set(true);
}
openViewAnnotationsDrawer(loadAnnotationCallback: (annotation: Annotation) => void) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewAnnotationsDrawerComponent, {position: 'end'});
ref.componentInstance.loadAnnotation.subscribe((annotation: Annotation) => {
loadAnnotationCallback(annotation);
});
ref.closed.subscribe(() => this.setDrawerClosed());
ref.dismissed.subscribe(() => this.setDrawerClosed());
this.isDrawerOpen.set(true);
}
openViewTocDrawer(chapterId: number, pageNum: number, callbackFn: (evt: LoadPageEvent | null) => void) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewTocDrawerComponent, {position: 'end'});
ref.componentInstance.chapterId.set(chapterId);
ref.componentInstance.pageNum.set(pageNum);
ref.componentInstance.loadPage.subscribe((res: LoadPageEvent | null) => {
// Check if we are on mobile to collapse the menu
if (this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Mobile) {
this.closeAll();
}
callbackFn(res);
});
ref.closed.subscribe(() => this.setDrawerClosed());
ref.dismissed.subscribe(() => this.setDrawerClosed());
this.isDrawerOpen.set(true);
}
openViewBookmarksDrawer(chapterId: number,
pageNum: number,
callbackFn: (evt: PageBookmark | null, action: 'loadPage' | 'removeBookmark') => void,
loadPtocCallbackFn: (evt: LoadPageEvent) => void) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(ViewBookmarkDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.chapterId.set(chapterId);
ref.componentInstance.pageNum.set(pageNum);
ref.componentInstance.loadPage.subscribe((res: PageBookmark | null) => {
// Check if we are on mobile to collapse the menu
if (this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Mobile) {
this.closeAll();
}
callbackFn(res, 'loadPage');
});
ref.componentInstance.loadPtoc.subscribe((res: LoadPageEvent) => {
// Check if we are on mobile to collapse the menu
if (this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Mobile) {
this.closeAll();
}
loadPtocCallbackFn(res);
});
ref.componentInstance.removeBookmark.subscribe((res: PageBookmark) => {
// Check if we are on mobile to collapse the menu
callbackFn(res, 'removeBookmark');
});
ref.closed.subscribe(() => this.setDrawerClosed());
ref.dismissed.subscribe(() => this.setDrawerClosed());
this.isDrawerOpen.set(true);
}
openSettingsDrawer(chapterId: number, seriesId: number, readingProfile: ReadingProfile, readerSettingsService: EpubReaderSettingsService) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
const ref = this.offcanvasService.open(EpubSettingDrawerComponent, {position: 'start', panelClass: ''});
ref.componentInstance.chapterId.set(chapterId);
ref.componentInstance.seriesId.set(seriesId);
ref.componentInstance.readingProfile.set(readingProfile);
ref.componentInstance.readerSettingsService.set(readerSettingsService);
ref.closed.subscribe(() => this.setDrawerClosed());
ref.dismissed.subscribe(() => this.setDrawerClosed());
this.isDrawerOpen.set(true);
}
openViewAnnotationDrawer(annotation: Annotation, editMode: boolean = false, callbackFn: (res: Annotation) => void) {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
if (!editMode && this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Tablet) {
// Open a modal to view the annotation?
}
const ref = this.offcanvasService.open(ViewEditAnnotationDrawerComponent, {position: 'bottom'});
ref.componentInstance.annotation.set(annotation);
(ref.componentInstance as ViewEditAnnotationDrawerComponent).mode.set(editMode ? AnnotationMode.Edit : AnnotationMode.View);
ref.closed.subscribe(() => this.setDrawerClosed());
ref.dismissed.subscribe(() => this.setDrawerClosed());
this.isDrawerOpen.set(true);
}
closeAll() {
if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss();
}
this.setDrawerClosed();
}
setDrawerClosed() {
this.isDrawerOpen.set(false);
}
}

View File

@ -0,0 +1,690 @@
import {computed, DestroyRef, effect, inject, Injectable, signal} from '@angular/core';
import {firstValueFrom, Observable, Subject} from 'rxjs';
import {bookColorThemes, PageStyle} from "../book-reader/_components/reader-settings/reader-settings.component";
import {ReadingDirection} from '../_models/preferences/reading-direction';
import {WritingStyle} from '../_models/preferences/writing-style';
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
import {FormControl, FormGroup, NonNullableFormBuilder} from "@angular/forms";
import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles";
import {BookService, FontFamily} from "../book-reader/_services/book.service";
import {ThemeService} from './theme.service';
import {ReadingProfileService} from "./reading-profile.service";
import {debounceTime, distinctUntilChanged, filter, skip, tap} from "rxjs/operators";
import {BookTheme} from "../_models/preferences/book-theme";
import {DOCUMENT} from "@angular/common";
import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {LayoutMeasurementService} from "./layout-measurement.service";
import {environment} from "../../environments/environment";
export interface ReaderSettingUpdate {
setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme';
object: any;
}
export type BookReadingProfileFormGroup = FormGroup<{
bookReaderMargin: FormControl<number>;
bookReaderLineSpacing: FormControl<number>;
bookReaderFontSize: FormControl<number>;
bookReaderFontFamily: FormControl<string>;
bookReaderTapToPaginate: FormControl<boolean>;
bookReaderReadingDirection: FormControl<ReadingDirection>;
bookReaderWritingStyle: FormControl<WritingStyle>;
bookReaderThemeName: FormControl<string>;
bookReaderLayoutMode: FormControl<BookPageLayoutMode>;
bookReaderImmersiveMode:FormControl <boolean>;
}>
const COLUMN_GAP = 20; //px gap between columns
@Injectable()
export class EpubReaderSettingsService {
private readonly destroyRef = inject(DestroyRef);
private readonly bookService = inject(BookService);
private readonly themeService = inject(ThemeService);
private readonly readingProfileService = inject(ReadingProfileService);
private readonly utilityService = inject(UtilityService);
private readonly toastr = inject(ToastrService);
private readonly document = inject(DOCUMENT);
private readonly fb = inject(NonNullableFormBuilder);
private readonly layoutMeasurements = inject(LayoutMeasurementService);
// Core signals - these will be the single source of truth
private readonly _currentReadingProfile = signal<ReadingProfile | null>(null);
private readonly _parentReadingProfile = signal<ReadingProfile | null>(null);
private readonly _currentSeriesId = signal<number | null>(null);
private readonly _isInitialized = signal<boolean>(false);
// Settings signals
private readonly _pageStyles = signal<PageStyle>(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements
private readonly _readingDirection = signal<ReadingDirection>(ReadingDirection.LeftToRight);
private readonly _writingStyle = signal<WritingStyle>(WritingStyle.Horizontal);
private readonly _activeTheme = signal<BookTheme | undefined>(undefined);
private readonly _clickToPaginate = signal<boolean>(false);
private readonly _layoutMode = signal<BookPageLayoutMode>(BookPageLayoutMode.Default);
private readonly _immersiveMode = signal<boolean>(false);
private readonly _isFullscreen = signal<boolean>(false);
// Form will be managed separately but updated from signals
private settingsForm!: BookReadingProfileFormGroup;
private fontFamilies: FontFamily[] = this.bookService.getFontFamilies();
private isUpdatingFromForm = false; // Flag to prevent infinite loops
private isInitialized = this._isInitialized(); // Non-signal, updates in effect
// Event subject for component communication (keep this for now, can be converted to effect later)
private settingUpdateSubject = new Subject<ReaderSettingUpdate>();
// Public readonly signals
public readonly currentReadingProfile = this._currentReadingProfile.asReadonly();
public readonly parentReadingProfile = this._parentReadingProfile.asReadonly();
// Settings as readonly signals
public readonly pageStyles = this._pageStyles.asReadonly();
public readonly readingDirection = this._readingDirection.asReadonly();
public readonly writingStyle = this._writingStyle.asReadonly();
public readonly activeTheme = this._activeTheme.asReadonly();
public readonly clickToPaginate = this._clickToPaginate.asReadonly();
public readonly immersiveMode = this._immersiveMode.asReadonly();
public readonly isFullscreen = this._isFullscreen.asReadonly();
// Computed signals for derived state
public readonly layoutMode = computed(() => {
const layout = this._layoutMode();
const mobileDevice = this.utilityService.activeUserBreakpoint() < UserBreakpoint.Tablet;
if (layout !== BookPageLayoutMode.Column2 || !mobileDevice) return layout;
// Do not use 2 column mode on small screens
this.toastr.info(translate('book-reader.force-selected-one-column'));
return BookPageLayoutMode.Column1;
});
public readonly canPromoteProfile = computed(() => {
const profile = this._currentReadingProfile();
return profile !== null && profile.kind === ReadingProfileKind.Implicit;
});
public readonly hasParentProfile = computed(() => {
return this._parentReadingProfile() !== null;
});
// Keep observable for now - can be converted to effect later
public readonly settingUpdates$ = this.settingUpdateSubject.asObservable()
.pipe(filter(val => {
if (!environment.production) {
console.log(`[SETTINGS EFFECT] ${val.setting}`, val.setting === 'theme' ? val.object.name : val.object);
}
return this._isInitialized();
}), debounceTime(10));
constructor() {
// Effect to update form when signals change (only when not updating from form)
effect(() => {
const profile = this._currentReadingProfile();
if (profile && this._isInitialized() && !this.isUpdatingFromForm) {
this.updateFormFromSignals();
}
});
effect(() => {
this.isInitialized = this._isInitialized();
});
// Effect to emit setting updates when signals change
effect(() => {
const styles = this._pageStyles();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'pageStyle',
object: styles,
});
});
effect(() => {
const clickToPaginate = this._clickToPaginate();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'clickToPaginate',
object: clickToPaginate,
});
});
effect(() => {
const mode = this._layoutMode();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'layoutMode',
object: mode,
});
});
effect(() => {
const direction = this._readingDirection();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'readingDirection',
object: direction,
});
});
effect(() => {
const style = this._writingStyle();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'writingStyle',
object: style,
});
});
effect(() => {
const mode = this._immersiveMode();
if (!this.isInitialized) return;
this.settingUpdateSubject.next({
setting: 'immersiveMode',
object: mode,
});
});
effect(() => {
const theme = this._activeTheme();
if (!this.isInitialized) return;
if (theme) {
this.settingUpdateSubject.next({
setting: 'theme',
object: theme
});
}
});
}
/**
* Initialize the service with a reading profile and series ID
*/
async initialize(seriesId: number, readingProfile: ReadingProfile): Promise<void> {
this._currentSeriesId.set(seriesId);
this._currentReadingProfile.set(readingProfile);
// Load parent profile if needed
if (readingProfile.kind === ReadingProfileKind.Implicit) {
try {
const parent = await firstValueFrom(this.readingProfileService.getForSeries(seriesId, true));
this._parentReadingProfile.set(parent || null);
} catch (error) {
console.error('Failed to load parent reading profile:', error);
}
}
// Setup defaults and update signals
this.setupDefaultsFromProfile(readingProfile);
this.setupSettingsForm();
// Set initial theme
const themeName = readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme;
this.setTheme(themeName, false);
// Mark as initialized - this will trigger effects to emit initial values
this._isInitialized.set(true);
}
/**
* Setup default values and update signals from profile
*/
private setupDefaultsFromProfile(profile: ReadingProfile): void {
// Set defaults if undefined
if (profile.bookReaderFontFamily === undefined) {
profile.bookReaderFontFamily = 'default';
}
if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) {
profile.bookReaderFontSize = 100;
}
if (profile.bookReaderLineSpacing === undefined || profile.bookReaderLineSpacing < 100) {
profile.bookReaderLineSpacing = 100;
}
if (profile.bookReaderMargin === undefined) {
profile.bookReaderMargin = 0;
}
if (profile.bookReaderReadingDirection === undefined) {
profile.bookReaderReadingDirection = ReadingDirection.LeftToRight;
}
if (profile.bookReaderWritingStyle === undefined) {
profile.bookReaderWritingStyle = WritingStyle.Horizontal;
}
if (profile.bookReaderLayoutMode === undefined) {
profile.bookReaderLayoutMode = BookPageLayoutMode.Default;
}
// Update signals from profile
this._readingDirection.set(profile.bookReaderReadingDirection);
this._writingStyle.set(profile.bookReaderWritingStyle);
this._clickToPaginate.set(profile.bookReaderTapToPaginate);
this._layoutMode.set(profile.bookReaderLayoutMode);
this._immersiveMode.set(profile.bookReaderImmersiveMode);
// Set up page styles
this.setPageStyles(
profile.bookReaderFontFamily,
profile.bookReaderFontSize + '%',
profile.bookReaderMargin + 'vw',
profile.bookReaderLineSpacing + '%'
);
}
/**
* Get the current settings form (for components that need direct form access)
*/
getSettingsForm(): BookReadingProfileFormGroup {
return this.settingsForm;
}
/**
* Get current reading profile
*/
getCurrentReadingProfile(): ReadingProfile | null {
return this._currentReadingProfile();
}
/**
* Get font families for UI
*/
getFontFamilies(): FontFamily[] {
return this.fontFamilies;
}
/**
* Get available themes
*/
getThemes(): BookTheme[] {
return bookColorThemes;
}
/**
* Toggle reading direction
*/
toggleReadingDirection(): void {
const current = this._readingDirection();
const newDirection = current === ReadingDirection.LeftToRight
? ReadingDirection.RightToLeft
: ReadingDirection.LeftToRight;
this._readingDirection.set(newDirection);
this.settingsForm.get('bookReaderReadingDirection')!.setValue(newDirection);
}
/**
* Toggle writing style
*/
toggleWritingStyle(): void {
const current = this._writingStyle();
const newStyle = current === WritingStyle.Horizontal
? WritingStyle.Vertical
: WritingStyle.Horizontal;
this._writingStyle.set(newStyle);
this.settingsForm.get('bookReaderWritingStyle')!.setValue(newStyle);
}
/**
* Set theme
*/
setTheme(themeName: string, update: boolean = true): void {
const theme = bookColorThemes.find(t => t.name === themeName);
if (theme) {
this._activeTheme.set(theme);
if (update) {
this.settingsForm.get('bookReaderThemeName')!.setValue(themeName);
}
}
}
updateLayoutMode(mode: BookPageLayoutMode): void {
this._layoutMode.set(mode);
// Update form control to keep in sync
this.settingsForm.get('bookReaderLayoutMode')?.setValue(mode, { emitEvent: false });
}
updateClickToPaginate(value: boolean): void {
this._clickToPaginate.set(value);
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(value);
}
updateReadingDirection(value: ReadingDirection): void {
this._readingDirection.set(value);
this.settingsForm.get('bookReaderReadingDirection')?.setValue(value);
}
updateWritingStyle(value: WritingStyle) {
this._writingStyle.set(value);
this.settingsForm.get('bookReaderWritingStyle')?.setValue(value);
}
updateFullscreen(value: boolean) {
this._isFullscreen.set(value);
if (!this._isInitialized()) return;
this.settingUpdateSubject.next({ setting: 'fullscreen', object: null }); // TODO: Refactor into an effect
}
updateImmersiveMode(value: boolean): void {
this._immersiveMode.set(value);
if (value) {
this._clickToPaginate.set(true);
}
}
/**
* Emit fullscreen toggle event
*/
toggleFullscreen(): void {
this.updateFullscreen(!this._isFullscreen());
}
/**
* Update parent reading profile preferences
*/
updateParentProfile(): void {
const currentRp = this._currentReadingProfile();
const seriesId = this._currentSeriesId();
if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit || !seriesId) {
return;
}
this.readingProfileService.updateParentProfile(seriesId, this.packReadingProfile())
.subscribe(newProfile => {
this._currentReadingProfile.set(newProfile);
this.toastr.success(translate('manga-reader.reading-profile-updated'));
});
}
/**
* Promote implicit profile to named profile
*/
promoteProfile(): Observable<ReadingProfile> {
const currentRp = this._currentReadingProfile();
if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit) {
throw new Error('Can only promote implicit profiles');
}
return this.readingProfileService.promoteProfile(currentRp.id).pipe(
tap(newProfile => {
this._currentReadingProfile.set(newProfile);
})
);
}
/**
* Update form controls from current signal values
*/
private updateFormFromSignals(): void {
const profile = this._currentReadingProfile();
if (!profile) return;
// Update form controls without triggering valueChanges
this.settingsForm.patchValue({
bookReaderFontFamily: profile.bookReaderFontFamily,
bookReaderFontSize: profile.bookReaderFontSize,
bookReaderTapToPaginate: this._clickToPaginate(),
bookReaderLineSpacing: profile.bookReaderLineSpacing,
bookReaderMargin: profile.bookReaderMargin,
bookReaderLayoutMode: this._layoutMode(),
bookReaderImmersiveMode: this._immersiveMode()
}, { emitEvent: false });
}
/**
* Sets up the reactive form and bidirectional binding with signals
*/
private setupSettingsForm(): void {
const profile = this._currentReadingProfile();
if (!profile) return;
// Recreate the form
this.settingsForm = new FormGroup({
bookReaderMargin: this.fb.control(profile.bookReaderMargin),
bookReaderLineSpacing: this.fb.control(profile.bookReaderLineSpacing),
bookReaderFontSize: this.fb.control(profile.bookReaderFontSize),
bookReaderFontFamily: this.fb.control(profile.bookReaderFontFamily),
bookReaderTapToPaginate: this.fb.control(this._clickToPaginate()),
bookReaderReadingDirection: this.fb.control(this._readingDirection()),
bookReaderWritingStyle: this.fb.control(profile.bookReaderWritingStyle),
bookReaderThemeName: this.fb.control(profile.bookReaderThemeName),
bookReaderLayoutMode: this.fb.control(this._layoutMode()),
bookReaderImmersiveMode: this.fb.control(this._immersiveMode()),
});
// Set up value change subscriptions
this.setupFormSubscriptions();
}
/**
* Sets up form value change subscriptions to update signals
*/
private setupFormSubscriptions(): void {
// Font family changes
this.settingsForm.get('bookReaderFontFamily')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(fontName => {
this.isUpdatingFromForm = true;
const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default';
const currentStyles = this._pageStyles();
const newStyles = { ...currentStyles };
if (familyName === 'default') {
newStyles['font-family'] = 'inherit';
} else {
newStyles['font-family'] = `'${familyName}'`;
}
this._pageStyles.set(newStyles);
this.isUpdatingFromForm = false;
});
// Font size changes
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
this.isUpdatingFromForm = true;
const currentStyles = this._pageStyles();
const newStyles = { ...currentStyles };
newStyles['font-size'] = value + '%';
this._pageStyles.set(newStyles);
this.isUpdatingFromForm = false;
});
// Tap to paginate changes
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
this.isUpdatingFromForm = true;
this._clickToPaginate.set(value);
this.isUpdatingFromForm = false;
});
// Line spacing changes
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
this.isUpdatingFromForm = true;
const currentStyles = this._pageStyles();
const newStyles = { ...currentStyles };
newStyles['line-height'] = value + '%';
this._pageStyles.set(newStyles);
this.isUpdatingFromForm = false;
});
// Margin changes
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
this.isUpdatingFromForm = true;
const currentStyles = this._pageStyles();
const newStyles = { ...currentStyles };
newStyles['margin-left'] = value + 'vw';
newStyles['margin-right'] = value + 'vw';
this._pageStyles.set(newStyles);
this.isUpdatingFromForm = false;
});
// Layout mode changes
this.settingsForm.get('bookReaderLayoutMode')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((layoutMode: BookPageLayoutMode) => {
this.isUpdatingFromForm = true;
this._layoutMode.set(layoutMode);
this.isUpdatingFromForm = false;
});
// Immersive mode changes
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((immersiveMode: boolean) => {
this.isUpdatingFromForm = true;
if (immersiveMode) {
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true, { emitEvent: false });
this._clickToPaginate.set(true);
}
this._immersiveMode.set(immersiveMode);
this.isUpdatingFromForm = false;
});
// Update implicit profile on form changes (debounced) - ONLY source of profile updates
this.settingsForm.valueChanges.pipe(
debounceTime(500),
distinctUntilChanged(),
skip(1), // Skip initial form creation
takeUntilDestroyed(this.destroyRef),
filter(() => !this.isUpdatingFromForm),
tap(() => this.updateImplicitProfile()),
).subscribe();
}
/**
* Resets a selection of settings to their default (Page Styles)
*/
resetSettings() {
const defaultStyles = this.getDefaultPageStyles();
this.setPageStyles(
defaultStyles["font-family"],
defaultStyles["font-size"],
defaultStyles['margin-left'],
defaultStyles['line-height'],
);
}
private updateImplicitProfile(): void {
if (!this._currentReadingProfile() || !this._currentSeriesId()) return;
this.readingProfileService.updateImplicit(this.packReadingProfile(), this._currentSeriesId()!)
.subscribe({
next: newProfile => {
this._currentReadingProfile.set(newProfile);
},
error: err => {
console.error('Failed to update implicit profile:', err);
}
});
}
/**
* Packs current settings into a ReadingProfile object
*/
private packReadingProfile(): ReadingProfile {
const currentProfile = this._currentReadingProfile();
if (!currentProfile) {
throw new Error('No current reading profile');
}
const modelSettings = this.settingsForm.getRawValue();
const data = { ...currentProfile };
// Update from form values
data.bookReaderFontFamily = modelSettings.bookReaderFontFamily;
data.bookReaderFontSize = modelSettings.bookReaderFontSize;
data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing;
data.bookReaderMargin = modelSettings.bookReaderMargin;
// Update from signals
data.bookReaderTapToPaginate = this._clickToPaginate();
data.bookReaderLayoutMode = this._layoutMode();
data.bookReaderImmersiveMode = this._immersiveMode();
data.bookReaderReadingDirection = this._readingDirection();
data.bookReaderWritingStyle = this._writingStyle();
const activeTheme = this._activeTheme();
if (activeTheme) {
data.bookReaderThemeName = activeTheme.name;
}
return data;
}
private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void {
const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth;
const mobileBreakpointMarginOverride = 700;
let defaultMargin = '15vw';
if (windowWidth <= mobileBreakpointMarginOverride) {
defaultMargin = '5vw';
}
const currentStyles = this._pageStyles();
const newStyles: PageStyle = {
'font-family': fontFamily || currentStyles['font-family'] || 'default',
'font-size': fontSize || currentStyles['font-size'] || '100%',
'margin-left': margin || currentStyles['margin-left'] || defaultMargin,
'margin-right': margin || currentStyles['margin-right'] || defaultMargin,
'line-height': lineHeight || currentStyles['line-height'] || '100%'
};
this._pageStyles.set(newStyles);
}
public getDefaultPageStyles(): PageStyle {
return {
'font-family': 'default',
'font-size': '100%',
'margin-left': '15vw',
'margin-right': '15vw',
'line-height': '100%'
};
}
createNewProfileFromImplicit() {
const rp = this.getCurrentReadingProfile();
if (rp === null || rp.kind !== ReadingProfileKind.Implicit) {
return;
}
this.promoteProfile().subscribe(newProfile => {
this._currentReadingProfile.set(newProfile);
this._parentReadingProfile.set(newProfile);
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
});
}
}

View File

@ -1,8 +1,8 @@
import {DestroyRef, inject, Injectable} from '@angular/core';
import { environment } from 'src/environments/environment';
import { ThemeService } from './theme.service';
import { RecentlyAddedItem } from '../_models/recently-added-item';
import { AccountService } from './account.service';
import {environment} from 'src/environments/environment';
import {ThemeService} from './theme.service';
import {RecentlyAddedItem} from '../_models/recently-added-item';
import {AccountService} from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Injectable({
@ -93,8 +93,8 @@ export class ImageService {
return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
}
getBookmarkedImage(chapterId: number, pageNum: number) {
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`;
getBookmarkedImage(chapterId: number, pageNum: number, imageOffset: number = 0) {
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}&imageOffset=${imageOffset}`;
}
getWebLinkImage(url: string) {

View File

@ -0,0 +1,169 @@
import {Injectable, OnDestroy, signal} from '@angular/core';
export interface LayoutMeasurements {
windowWidth: number,
windowHeight: number,
contentWidth: number,
contentHeight: number,
scrollWidth: number,
scrollHeight: number,
readerWidth: number,
readerHeight: number,
}
/**
* Used in Epub reader to simplify
*/
@Injectable()
export class LayoutMeasurementService implements OnDestroy {
private resizeObserver?: ResizeObserver;
private rafId?: number;
private observedElements = new Map<string, HTMLElement>();
private readingSectionElement?: HTMLElement;
private bookContentElement?: HTMLElement;
// Public signals for components to consume
readonly measurements = signal<LayoutMeasurements>({
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
contentWidth: 0,
contentHeight: 0,
scrollWidth: 0,
scrollHeight: 0,
readerWidth: 0,
readerHeight: 0,
});
constructor() {
this.initializeObservers();
this.setupWindowListeners();
}
private initializeObservers() {
// ResizeObserver for element size changes
this.resizeObserver = new ResizeObserver(entries => {
this.scheduleUpdate(() => this.handleResize(entries));
});
}
private setupWindowListeners() {
window.addEventListener('resize', this.updateWindowMeasurements.bind(this));
window.addEventListener('orientationchange', this.updateWindowMeasurements.bind(this));
}
/**
* Start observing an element for size changes
*/
observeElement(element: HTMLElement, key: 'readingSection' | 'bookContent') {
if (this.observedElements.has(key)) {
this.unobserveElement(key);
}
this.observedElements.set(key, element);
this.resizeObserver?.observe(element);
// Store reference to key elements
if (key === 'readingSection') {
this.readingSectionElement = element;
} else if (key === 'bookContent') {
this.bookContentElement = element;
}
// Initial measurement
this.measureElement(element, key);
}
/**
* Stop observing an element
*/
unobserveElement(key: string): void {
const element = this.observedElements.get(key);
if (element) {
this.resizeObserver?.unobserve(element);
this.observedElements.delete(key);
}
}
private handleResize(entries: ResizeObserverEntry[]): void {
const updates: Partial<LayoutMeasurements> = {};
entries.forEach(entry => {
const key = Array.from(this.observedElements.entries())
.find(([_, el]) => el === entry.target)?.[0];
if (!key) return;
// Use borderBoxSize when available (more accurate)
const size = entry.borderBoxSize?.[0] || entry.contentRect;
switch(key) {
case 'bookContent':
updates.contentWidth = size.inlineSize || 0;
updates.contentHeight = size.blockSize || 0;
updates.scrollWidth = (entry.target as HTMLElement).scrollWidth;
updates.scrollHeight = (entry.target as HTMLElement).scrollHeight;
break;
case 'readingSection':
updates.readerWidth = size.inlineSize || 0;
updates.readerHeight = size.blockSize || 0;
break;
}
});
this.measurements.update(current => ({ ...current, ...updates }));
}
private measureElement(element: HTMLElement, key: string): void {
const rect = element.getBoundingClientRect();
const updates: Partial<LayoutMeasurements> = {};
switch(key) {
case 'bookContent':
updates.contentWidth = rect.width;
updates.contentHeight = rect.height;
updates.scrollWidth = element.scrollWidth;
updates.scrollHeight = element.scrollHeight;
break;
case 'readingSection':
updates.readerWidth = rect.width;
updates.readerHeight = rect.height;
break;
}
this.measurements.update(current => ({ ...current, ...updates }));
}
private updateWindowMeasurements(): void {
this.measurements.update(current => ({
...current,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
}));
}
private scheduleUpdate(callback: () => void): void {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(() => {
callback();
this.rafId = undefined;
});
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
}
}

View File

@ -11,6 +11,8 @@ import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event";
import {AnnotationUpdateEvent} from "../_models/events/annotation-update-event";
import {toSignal} from "@angular/core/rxjs-interop";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -118,7 +120,11 @@ export enum EVENTS {
/**
* A Rate limit error was hit when matching a series with Kavita+
*/
ExternalMatchRateLimitError = 'ExternalMatchRateLimitError'
ExternalMatchRateLimitError = 'ExternalMatchRateLimitError',
/**
* Annotation is updated within the reader
*/
AnnotationUpdate = 'AnnotationUpdate',
}
export interface Message<T> {
@ -140,11 +146,13 @@ export class MessageHubService {
/**
* Any events that come from the backend
*/
public messages$ = this.messagesSource.asObservable();
public readonly messages$ = this.messagesSource.asObservable();
public readonly messageSignal = toSignal(this.messages$);
/**
* Users that are online
*/
public onlineUsers$ = this.onlineUsersSource.asObservable();
public readonly onlineUsersSignal = toSignal(this.onlineUsers$);
constructor() {}
@ -248,6 +256,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.AnnotationUpdate, resp => {
this.messagesSource.next({
event: EVENTS.AnnotationUpdate,
payload: resp.body as AnnotationUpdateEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,

View File

@ -1,5 +1,5 @@
import {HttpClient} from '@angular/common/http';
import {DestroyRef, Inject, inject, Injectable} from '@angular/core';
import {DestroyRef, inject, Injectable} from '@angular/core';
import {DOCUMENT, Location} from '@angular/common';
import {Router} from '@angular/router';
import {environment} from 'src/environments/environment';
@ -40,6 +40,8 @@ export class ReaderService {
private readonly location = inject(Location);
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
private readonly httpClient = inject(HttpClient);
private readonly document = inject(DOCUMENT);
baseUrl = environment.apiUrl;
encodedKey: string = '';
@ -50,7 +52,7 @@ export class ReaderService {
private noSleep: NoSleep = new NoSleep();
constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) {
constructor() {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user) {
this.encodedKey = encodeURIComponent(user.apiKey);
@ -100,12 +102,12 @@ export class ReaderService {
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
}
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0, xpath: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, imageNumber, xpath});
}
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber});
}
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
@ -222,6 +224,10 @@ export class ReaderService {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
}
getTimeLeftForChapter(seriesId: number, chapterId: number) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + `reader/time-left-for-chapter?seriesId=${seriesId}&chapterId=${chapterId}`);
}
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/
@ -309,13 +315,18 @@ export class ReaderService {
/**
* Closes the reader and causes a redirection
*/
closeReader(readingListMode: boolean = false, readingListId: number = 0) {
closeReader(libraryId: number, seriesId: number, chapterId: number, readingListMode: boolean = false, readingListId: number = 0) {
if (readingListMode) {
this.router.navigateByUrl('lists/' + readingListId);
} else {
// TODO: back doesn't always work, it might be nice to check the pattern of the url and see if we can be smart before just going back
this.location.back();
return
}
if (window.history.length > 1) {
this.location.back();
return;
}
this.router.navigateByUrl(`/library/${libraryId}/series/${seriesId}/chapter/${chapterId}`);
}
removePersonalToc(chapterId: number, pageNumber: number, title: string) {
@ -326,43 +337,256 @@ export class ReaderService {
return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId);
}
createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) {
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId});
createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null, selectedText: string) {
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText});
}
getElementFromXPath(path: string) {
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node?.nodeType === Node.ELEMENT_NODE) {
return node as Element;
try {
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node?.nodeType === Node.ELEMENT_NODE) {
return node as Element;
}
return null;
} catch (e) {
console.debug("Failed to evaluate XPath:", path, " exception:", e)
return null;
}
return null;
}
/**
* Removes the Kavita UI aspect of the xpath from a given xpath variable
* Used for Annotations and Bookmarks within epub reader
*
* @param xpath
*/
descopeBookReaderXpath(xpath: string) {
if (xpath.startsWith("id(")) return xpath;
const bookContentElement = this.document.querySelector('.book-content');
if (!bookContentElement?.children[0]) {
console.warn('Book content element not found, returning original xpath');
return xpath;
}
const bookContentXPath = this.getXPathTo(bookContentElement.children[0], true);
// Normalize both paths
const normalizedXpath = this.normalizeXPath(xpath);
const normalizedBookContentXPath = this.normalizeXPath(bookContentXPath);
//console.log('Descoping - Original:', xpath);
//console.log('Descoping - Normalized xpath:', normalizedXpath);
//console.log('Descoping - Book content path:', normalizedBookContentXPath);
// Find the UI container pattern and extract content path
const descopedPath = this.extractContentPath(normalizedXpath, normalizedBookContentXPath);
//console.log('Descoped', xpath, 'to', descopedPath);
return descopedPath;
}
/**
* Adds the Kavita UI aspect to the xpath so loading from xpath in the reader works
* @param xpath
*/
scopeBookReaderXpath(xpath: string) {
if (xpath.startsWith("id(")) return xpath;
const bookContentElement = this.document.querySelector('.book-content');
if (!bookContentElement?.children[0]) {
console.warn('Book content element not found, returning original xpath');
return xpath;
}
const bookContentXPath = this.getXPathTo(bookContentElement.children[0], true);
const normalizedXpath = this.normalizeXPath(xpath);
const normalizedBookContentXPath = this.normalizeXPath(bookContentXPath);
// If already scoped, return as-is
if (normalizedXpath.includes(normalizedBookContentXPath)) {
return xpath;
}
// Replace //body with the actual book content path
if (normalizedXpath.startsWith('//body')) {
const relativePath = normalizedXpath.substring(6); // Remove '//body'
return bookContentXPath + relativePath;
}
// If it starts with /body, replace with book content path
if (normalizedXpath.startsWith('/body')) {
const relativePath = normalizedXpath.substring(5); // Remove '/body'
return bookContentXPath + relativePath;
}
// Default: prepend the book content path
return bookContentXPath + (normalizedXpath.startsWith('/') ? normalizedXpath : '/' + normalizedXpath);
}
/**
* Extract the content path by finding the UI container boundary
*/
private extractContentPath(fullXpath: string, bookContentXPath: string): string {
// Look for the pattern where the book content container ends
// The book content path should be a prefix of the full path
// First, try direct substring match
if (fullXpath.startsWith(bookContentXPath)) {
const contentPath = fullXpath.substring(bookContentXPath.length);
return '//body' + (contentPath.startsWith('/') ? contentPath : '/' + contentPath);
}
// If direct match fails, try to find the common UI structure pattern
// Look for the app-book-reader container end point
const readerPattern = /\/app-book-reader\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]/;
const match = fullXpath.match(readerPattern);
if (match) {
const containerEndIndex = fullXpath.indexOf(match[0]) + match[0].length;
const contentPath = fullXpath.substring(containerEndIndex);
return '//body' + (contentPath.startsWith('/') ? contentPath : '/' + contentPath);
}
// Alternative approach: look for the deepest common path structure
// Split both paths and find where they diverge after the UI container
const fullParts = fullXpath.split('/').filter(p => p.length > 0);
const bookParts = bookContentXPath.split('/').filter(p => p.length > 0);
// Find the app-book-reader index in full path
const readerIndex = fullParts.findIndex(part => part.startsWith('app-book-reader'));
if (readerIndex !== -1) {
// Look for the pattern after app-book-reader that matches book content structure
// Typically: app-book-reader[1]/div[1]/div[2]/div[3]/div[1]/div[1] then content starts
let contentStartIndex = readerIndex + 6; // Skip the typical 6 div containers
// Adjust based on actual book content depth
const bookReaderIndex = bookParts.findIndex(part => part.startsWith('app-book-reader'));
if (bookReaderIndex !== -1) {
const expectedDepth = bookParts.length - bookReaderIndex - 1;
contentStartIndex = readerIndex + 1 + expectedDepth;
}
if (contentStartIndex < fullParts.length) {
const contentParts = fullParts.slice(contentStartIndex);
return '//body/' + contentParts.join('/');
}
}
// Fallback: clean common UI prefixes
return this.cleanCommonUIPrefixes(fullXpath);
}
/**
* Normalize XPath by cleaning common variations and converting to lowercase
*/
private normalizeXPath(xpath: string): string {
let normalized = xpath.toLowerCase();
// Remove common HTML document prefixes
const prefixesToRemove = [
'//html[1]//body',
'//html[1]//app-root[1]',
'//html//body',
'//html//app-root[1]'
];
for (const prefix of prefixesToRemove) {
if (normalized.startsWith(prefix)) {
normalized = '//body' + normalized.substring(prefix.length);
break;
}
}
return normalized;
}
/**
* Clean common UI prefixes that shouldn't be in descoped paths
*/
private cleanCommonUIPrefixes(xpath: string): string {
let cleaned = xpath;
// Remove app-root references
cleaned = cleaned.replace(/\/app-root\[\d+\]/g, '');
// Ensure it starts with //body
if (!cleaned.startsWith('//body') && !cleaned.startsWith('/body')) {
// Try to find body in the path
const bodyIndex = cleaned.indexOf('/body');
if (bodyIndex !== -1) {
cleaned = '//' + cleaned.substring(bodyIndex + 1);
} else {
// If no body found, assume it should start with //body
cleaned = '//body' + (cleaned.startsWith('/') ? cleaned : '/' + cleaned);
}
}
return cleaned;
}
/**
*
* @param element
* @param pureXPath Will ignore shortcuts like id('')
*/
getXPathTo(element: any, pureXPath = false): string {
if (element === null) return '';
if (!pureXPath) {
if (element.id !== '') { return 'id("' + element.id + '")'; }
if (element === document.body) { return element.tagName; }
if (!element) {
console.error('getXPathTo: element is null or undefined');
return '';
}
let xpath = this.getXPath(element, pureXPath);
let ix = 0;
const siblings = element.parentNode?.childNodes || [];
for (let sibling of siblings) {
// Ensure xpath starts with // for absolute paths
if (xpath && !xpath.startsWith('//') && !xpath.startsWith('id(')) {
xpath = '//' + xpath;
}
return xpath;
}
private getXPath(element: HTMLElement, pureXPath = false): string {
if (!element) {
console.error('getXPath: element is null or undefined');
return '';
}
// Handle shortcuts (unless pureXPath is requested)
if (!pureXPath && element.id) {
return `id("${element.id}")`;
}
if (element === document.body) {
return 'body';
}
if (!element.parentNode) {
return element.tagName.toLowerCase();
}
// Count same-tag siblings
let siblingIndex = 1;
const siblings = Array.from(element.parentNode?.children ?? []);
const tagName = element.tagName;
for (const sibling of siblings) {
if (sibling === element) {
return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
break;
}
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
ix++;
if (sibling.tagName === tagName) {
siblingIndex++;
}
}
return '';
const currentPath = `${element.tagName.toLowerCase()}[${siblingIndex}]`;
const parentPath = this.getXPath(element.parentElement!, pureXPath);
return parentPath ? `${parentPath}/${currentPath}` : currentPath;
}
readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) {
@ -390,4 +614,5 @@ export class ReaderService {
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
{queryParams: {incognitoMode}});
}
}

View File

@ -1,24 +1,56 @@
import { ElementRef, Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter, ReplaySubject } from 'rxjs';
import {ElementRef, inject, Injectable, signal} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router';
import {filter, ReplaySubject} from 'rxjs';
const DEFAULT_TIMEOUT = 3000;
const DEFAULT_TOLERANCE = 3;
const DEFAULT_DEBOUNCE = 100;
interface ScrollEndOptions {
tolerance?: number;
timeout?: number;
debounce?: number;
}
interface ScrollToOptions {
scrollIntoViewOptions: ScrollIntoViewOptions;
timeout: number;
}
interface ScrollHandler {
timeoutId?: number;
callback?: () => void;
targetPosition?: { x?: number; y?: number };
tolerance: number;
cleanup?: () => void;
}
@Injectable({
providedIn: 'root'
})
export class ScrollService {
private scrollContainerSource = new ReplaySubject<string | ElementRef<HTMLElement>>(1);
private readonly router = inject(Router);
private readonly debugMode = false;
private readonly scrollContainerSource = new ReplaySubject<string | ElementRef<HTMLElement>>(1);
/**
* Exposes the current container on the active screen that is our primary overlay area. Defaults to 'body' and changes to 'body' on page loads
*/
public scrollContainer$ = this.scrollContainerSource.asObservable();
public readonly scrollContainer$ = this.scrollContainerSource.asObservable();
constructor(router: Router) {
private activeScrollHandlers = new Map<HTMLElement, ScrollHandler>();
router.events
private readonly _lock = signal(false);
public readonly isScrollingLock = this._lock.asReadonly();
constructor() {
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.scrollContainerSource.next('body');
this.cleanup();
});
this.scrollContainerSource.next('body');
}
@ -38,18 +70,92 @@ export class ScrollService {
|| document.body.scrollLeft || 0);
}
scrollTo(top: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'smooth') {
el.scroll({
top: top,
behavior: behavior
});
/**
* Returns true if the log is active
* @private
*/
private checkLock(): boolean {
return false; // NOTE: We don't need locking anymore - it bugs out
if (!this._lock()) return false;
console.warn("[ScrollService] tried to scroll while locked, timings should be checked")
return true;
}
scrollToX(left: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'auto') {
el.scroll({
left: left,
private intersectionObserver(element: HTMLElement, callback?: () => void) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.executeCallback(element, callback);
}
});
}, {threshold: 1.0});
observer.observe(element);
return observer;
}
scrollIntoView(element: HTMLElement, options?: ScrollToOptions, callback?: () => void) {
if (this.checkLock()) return;
this._lock.set(true);
const timeoutId = window.setTimeout(() => {
console.warn('Intersection observer timed out - forcing callback execution');
this.executeCallback(element, callback)
}, DEFAULT_TIMEOUT)
const observer = this.intersectionObserver(element, callback);
const scrollHandler: ScrollHandler = {
timeoutId: timeoutId,
callback: callback,
tolerance: 0,
cleanup: () => {
observer.disconnect();
observer.unobserve(element);
clearTimeout(timeoutId);
},
}
this.activeScrollHandlers.set(element, scrollHandler);
if (options?.timeout || 0) {
setTimeout(() => element.scrollIntoView(options?.scrollIntoViewOptions), options?.timeout || 0);
} else {
element.scrollIntoView(options?.scrollIntoViewOptions);
}
}
scrollTo(position: number, element: HTMLElement, behavior: 'auto' | 'smooth' = 'smooth',
onComplete?: () => void, options?: ScrollEndOptions) {
if (this.checkLock()) return;
this._lock.set(true);
element.scrollTo({
top: position,
behavior: behavior
});
if (onComplete) {
this.onScrollEnd((element as HTMLElement), onComplete, { y: position }, options);
}
}
scrollToX(position: number, element: HTMLElement, behavior: 'auto' | 'smooth' = 'auto',
onComplete?: () => void, options?: ScrollEndOptions) {
if (this.checkLock()) return;
this._lock.set(true);
element.scrollTo({
left: position,
behavior: behavior
});
if (onComplete) {
this.onScrollEnd((element as HTMLElement), onComplete, { x: position }, options);
}
}
setScrollContainer(elem: ElementRef<HTMLElement> | undefined) {
@ -57,4 +163,155 @@ export class ScrollService {
this.scrollContainerSource.next(elem);
}
}
/**
* Register scroll end callback
*/
private onScrollEnd(
element: HTMLElement,
callback: () => void,
targetPosition?: { x?: number; y?: number },
options?: ScrollEndOptions
): void {
const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE;
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const debounce = options?.debounce ?? DEFAULT_DEBOUNCE;
this.clearScrollHandler(element);
let debounceTimer: number;
let scrollEventCount = 0;
const checkComplete = () => {
const currentX = element.scrollLeft;
const currentY = element.scrollTop;
if (targetPosition) {
let isComplete = true;
let deltaInfo: any = {};
if (targetPosition.x !== undefined) {
const deltaX = Math.abs(currentX - targetPosition.x);
deltaInfo.deltaX = deltaX;
if (deltaX > tolerance) {
isComplete = false;
}
}
if (targetPosition.y !== undefined) {
const deltaY = Math.abs(currentY - targetPosition.y);
deltaInfo.deltaY = deltaY;
if (deltaY > tolerance) {
isComplete = false;
}
}
this.debugLog('Completion check:', {
isComplete,
...deltaInfo,
tolerance
});
if (isComplete) {
this.debugLog('Scroll completed successfully');
this.executeCallback(element, callback);
return;
}
}
};
const scrollHandler = () => {
scrollEventCount++;
this.debugLog(`Scroll event #${scrollEventCount}`);
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.debugLog('Scroll debounce timeout reached');
checkComplete();
if (!targetPosition) {
this.debugLog('No target position - completing');
this.executeCallback(element, callback);
}
}, debounce);
};
// Rest of your existing scroll handler setup...
const handlerData: ScrollHandler = {
callback,
targetPosition,
tolerance,
timeoutId: window.setTimeout(() => {
this.executeCallback(element, callback);
}, timeout)
};
this.activeScrollHandlers.set(element, handlerData);
element.addEventListener('scroll', scrollHandler, { passive: true });
handlerData.cleanup = () => {
this.debugLog('Cleaning up scroll handler');
element.removeEventListener('scroll', scrollHandler);
clearTimeout(debounceTimer);
if (handlerData.timeoutId) {
clearTimeout(handlerData.timeoutId);
}
};
// Check immediately for instant scrolls
setTimeout(() => {
this.debugLog('Initial completion check');
checkComplete();
}, 50);
}
private executeCallback(element: HTMLElement, callback?: () => void): void {
this._lock.set(false);
this.clearScrollHandler(element);
if (!callback) return;
try {
callback();
} catch (error) {
console.error('Error in scroll completion callback:', error);
}
}
private clearScrollHandler(element: HTMLElement): void {
const handler = this.activeScrollHandlers.get(element);
if (!handler) return;
this.activeScrollHandlers.delete(element);
if (handler.cleanup) {
handler.cleanup();
}
}
/**
* Clean up all handlers
*/
cleanup(): void {
this.activeScrollHandlers.forEach((handler, element) => {
this.clearScrollHandler(element);
});
}
/**
* Force unlocking of scroll lock
*/
unlock() {
this._lock.set(false);
this.cleanup();
}
private debugLog(message: string, extraData?: any) {
if (!this.debugMode) return;
if (extraData !== undefined) {
console.log(message, extraData);
} else {
console.log(message);
}
}
}

View File

@ -0,0 +1,9 @@
<div class="mb-3" *transloco="let t;prefix:'annotations-tab'">
<app-carousel-reel [items]="annotations()" [alwaysShow]="false">
<ng-template #carouselItem let-item let-position="idx">
<div style="min-width: 200px">
<app-annotation-card [annotation]="item" [allowEdit]="false" [showPageLink]="false" [isInReader]="false" [showInReaderLink]="true"></app-annotation-card>
</div>
</ng-template>
</app-carousel-reel>
</div>

View File

@ -0,0 +1,23 @@
import {Component, input} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {Annotation} from "../../book-reader/_models/annotations/annotation";
import {
AnnotationCardComponent
} from "../../book-reader/_components/_annotations/annotation-card/annotation-card.component";
@Component({
selector: 'app-annotations-tab',
imports: [
CarouselReelComponent,
TranslocoDirective,
AnnotationCardComponent
],
templateUrl: './annotations-tab.component.html',
styleUrl: './annotations-tab.component.scss'
})
export class AnnotationsTabComponent {
annotations = input.required<Annotation[]>();
}

View File

@ -33,7 +33,7 @@
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch" switch>
</div>
</ng-template>
</app-setting-switch>

View File

@ -16,7 +16,7 @@
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
<form [formGroup]="bulkForm">
<div class="form-check form-switch">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help" switch>
<label class="form-check-label" for="bulk-action-type">{{t('include-type-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="left" [ngbTooltip]="includeTypeTooltip" role="button" tabindex="0"></i>
<ng-template #includeTypeTooltip>{{t('include-type-tooltip')}}</ng-template>

View File

@ -1,19 +1,12 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef, effect,
HostListener,
inject,
OnInit
} from '@angular/core';
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject, OnInit} from '@angular/core';
import {NavigationStart, Router, RouterOutlet} from '@angular/router';
import {map, shareReplay, take, tap} from 'rxjs/operators';
import {map, shareReplay, take} from 'rxjs/operators';
import {AccountService} from './_services/account.service';
import {LibraryService} from './_services/library.service';
import {NavService} from './_services/nav.service';
import {NgbModal, NgbModalConfig, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap';
import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common';
import {filter, interval, Observable, switchMap} from 'rxjs';
import {filter, Observable} from 'rxjs';
import {ThemeService} from "./_services/theme.service";
import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.component';
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
@ -120,11 +113,15 @@ export class AppComponent implements OnInit {
const user = this.accountService.currentUserSignal();
if (!user) return;
// Refresh the user data
this.accountService.refreshAccount().subscribe(account => {
if (this.accountService.hasAdminRole(user)) {
this.licenseService.licenseInfo().subscribe();
}
});
// Bootstrap anything that's needed
this.themeService.getThemes().subscribe();
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
if (this.accountService.hasAdminRole(user)) {
this.licenseService.licenseInfo().subscribe();
}
}
}

View File

@ -0,0 +1,67 @@
<ng-container *transloco="let t; prefix: 'annotation-card'">
<div class="card border-0 shadow-sm mb-3">
<div #username class="card-header d-flex justify-content-between align-items-center py-2 px-3 clickable" [ngStyle]="{'background-color': titleColor()}" (click)="viewAnnotation()">
<div class="d-flex align-items-center">
<strong [style.color]="colorscapeService.getContrastingTextColor(username)">{{ annotation().ownerUsername }}</strong>
</div>
<div [style.color]="colorscapeService.getContrastingTextColor(username)" class="ms-2">{{ annotation().createdUtc | utcToLocaleDate | date: 'shortDate' }}</div>
</div>
<div class="card-body px-3 py-2">
<blockquote class="mb-2 text-muted small fst-italic fw-lighter">
<p class="content-quote">{{annotation().selectedText}}</p>
</blockquote>
<div class="mb-2 small text-muted">
@let content = annotation().comment;
@if (content !== '\"\"') {
<quill-view [content]="annotation().comment" format="json" theme="snow"></quill-view>
} @else {
{{null | defaultValue}}
}
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center py-2 px-3">
@if(annotation().containsSpoiler) {
<div class="d-flex align-items-center">
<span class="small text-muted">
<i class="fa-solid fa-circle-exclamation me-1" aria-hidden="true"></i>
{{t('contains-spoilers-label')}}
</span>
</div>
}
<div class="d-flex align-items-center gap-2">
@if(showInReaderLink()) {
<a target="_blank" [routerLink]="['/library', annotation().libraryId, 'series', annotation().seriesId, 'book', annotation().chapterId]"
[queryParams]="{annotation: annotation().id, incognitoMode: false}">{{t('view-in-reader-label')}}</a>
}
@if(showPageLink()) {
<span class="badge bg-secondary clickable" (click)="loadAnnotation()">{{t('page-num', {page: annotation().pageNumber})}}</span>
}
@if (false) {
<button class="btn btn-sm btn-outline-primary">
<i class="fa-solid fa-thumbs-up" aria-hidden="true"></i>
</button>
}
@if(allowEdit()) {
<button class="btn btn-sm btn-outline-secondary" (click)="editAnnotation()">
<i class="fa-solid fa-pen-alt" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="deleteAnnotation()">
<i class="fa-solid fa-trash-can" aria-hidden="true"></i>
</button>
}
</div>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,17 @@
.card {
max-width: 400px;
}
.content-quote {
color: var(--drawer-text-color);
max-height: 320px;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
:host ::ng-deep quill-view {
color: var(--drawer-text-color);
}

View File

@ -0,0 +1,128 @@
import {
ChangeDetectionStrategy,
Component,
computed,
EventEmitter,
inject,
input,
model,
Output,
Signal
} from '@angular/core';
import {Annotation} from "../../../_models/annotations/annotation";
import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe";
import {QuillViewComponent} from "ngx-quill";
import {DatePipe, NgStyle} from "@angular/common";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ConfirmService} from "../../../../shared/confirm.service";
import {AnnotationService} from "../../../../_services/annotation.service";
import {EpubReaderMenuService} from "../../../../_services/epub-reader-menu.service";
import {DefaultValuePipe} from "../../../../_pipes/default-value.pipe";
import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe";
import {ColorscapeService} from "../../../../_services/colorscape.service";
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
@Component({
selector: 'app-annotation-card',
imports: [
UtcToLocaleDatePipe,
QuillViewComponent,
DatePipe,
TranslocoDirective,
DefaultValuePipe,
NgStyle,
RouterLink
],
templateUrl: './annotation-card.component.html',
styleUrl: './annotation-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AnnotationCardComponent {
protected readonly colorscapeService = inject(ColorscapeService);
private readonly confirmService = inject(ConfirmService);
private readonly annotationService = inject(AnnotationService);
private readonly epubMenuService = inject(EpubReaderMenuService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly highlightSlotPipe = new SlotColorPipe();
annotation = model.required<Annotation>();
allowEdit = input<boolean>(true);
showPageLink = input<boolean>(true);
/**
* Redirects to the reader with annotation in view
*/
showInReaderLink = input<boolean>(false);
isInReader = input<boolean>(true);
@Output() delete = new EventEmitter();
@Output() navigate = new EventEmitter<Annotation>();
titleColor: Signal<string>;
constructor() {
// TODO: Validate if I want this -- aka update content on a detail page when receiving update from backend
// this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
// if (message.payload !== EVENTS.AnnotationUpdate) return;
// const updatedAnnotation = message.payload as AnnotationUpdateEvent;
// if (this.annotation()?.id !== updatedAnnotation.annotation.id) return;
//
// console.log('Refreshing annotation from backend: ', updatedAnnotation.annotation);
// this.annotation.set(updatedAnnotation.annotation);
// });
this.titleColor = computed(() => {
const annotation = this.annotation();
const slots = this.annotationService.slots();
if (!annotation || annotation.selectedSlotIndex < 0 || annotation.selectedSlotIndex >= slots.length) return '';
return this.highlightSlotPipe.transform(slots[annotation.selectedSlotIndex].color);
});
}
loadAnnotation() {
// Check if the url is within the reader or not
// If within the reader, we can use a event to allow drawer to load
// If outside the reader, we need to use a load reader with a special handler
if (this.isInReader()) {
this.navigate.emit(this.annotation());
return;
}
// If outside the reader, we need to use a load reader with a special handler
const queryParams = { ...this.route.snapshot.queryParams };
queryParams['annotation'] = this.annotation().id + '';
// Navigate to same route with updated query params
this.router.navigate([], {
relativeTo: this.route,
queryParams,
replaceUrl: false
});
}
editAnnotation() {
this.epubMenuService.openViewAnnotationDrawer(this.annotation(), true, (updatedAnnotation: Annotation) => {
this.annotation.set(updatedAnnotation);
});
}
viewAnnotation() {
this.epubMenuService.openViewAnnotationDrawer(this.annotation(), false, (updatedAnnotation: Annotation) => {
this.annotation.set(updatedAnnotation);
});
}
async deleteAnnotation() {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'))) return;
const annotation = this.annotation();
if (!annotation) return;
this.annotationService.delete(annotation.id).subscribe(_ => {
this.delete.emit();
});
}
}

View File

@ -0,0 +1,5 @@
<span class="epub-highlight clickable" #highlightSpan>
<span class="epub-highlight" [ngStyle]="{'background-color': highlightStyle()}" (click)="viewAnnotation()">
<ng-content />
</span>
</span>

View File

@ -0,0 +1,10 @@
.icon-spacer {
font-size: 0.85rem;
}
.epub-highlight {
position: relative;
display: inline;
transition: all 0.2s ease-in-out;
}

View File

@ -0,0 +1,69 @@
import {Component, computed, DestroyRef, effect, ElementRef, inject, input, model, ViewChild} from '@angular/core';
import {Annotation, HighlightColor} from "../../../_models/annotations/annotation";
import {EpubReaderMenuService} from "../../../../_services/epub-reader-menu.service";
import {AnnotationService} from "../../../../_services/annotation.service";
import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe";
import {NgStyle} from "@angular/common";
import {MessageHubService} from "../../../../_services/message-hub.service";
@Component({
selector: 'app-epub-highlight',
imports: [
NgStyle
],
templateUrl: './epub-highlight.component.html',
styleUrl: './epub-highlight.component.scss'
})
export class EpubHighlightComponent {
private readonly epubMenuService = inject(EpubReaderMenuService);
private readonly annotationService = inject(AnnotationService);
private readonly messageHub = inject(MessageHubService);
private readonly destroyRef = inject(DestroyRef);
showHighlight = model<boolean>(true);
color = input<HighlightColor>(HighlightColor.Blue);
annotation = model.required<Annotation | null>();
@ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef;
private readonly highlightSlotPipe = new SlotColorPipe();
constructor() {
effect(() => {
const updateEvent = this.annotationService.events();
const annotation = this.annotation();
const annotations = this.annotationService.annotations();
if (!updateEvent || !annotation || updateEvent.annotation.id !== annotation.id) return;
if (updateEvent.type !== 'edit') return;
console.log('[highlight] annotation updated', annotation);
this.annotation.set(annotations.filter(a => a.id === annotation.id)[0]);
});
}
highlightStyle = computed(() => {
const showHighlight = this.showHighlight();
const annotation = this.annotation();
const slots = this.annotationService.slots();
if (!showHighlight || !annotation || slots.length === 0 || slots.length < annotation.selectedSlotIndex) {
return '';
}
return this.highlightSlotPipe.transform(slots[annotation.selectedSlotIndex].color);
});
viewAnnotation() {
// Don't view annotation if a drawer is already open
if (this.epubMenuService.isDrawerOpen()) return;
this.epubMenuService.openViewAnnotationDrawer(this.annotation()!, false, (_) => {});
}
}

View File

@ -0,0 +1,55 @@
<!-- Updated highlight-bar template -->
<ng-container *transloco="let t; prefix:'highlight-bar'">
<div class="d-flex align-items-center" style="max-height: 47px; height: 47px;">
@let collapsed = isCollapsed();
@if (desktopLayout() && canCollapse()) {
<button
type="button"
style="max-height: 47px"
class="btn btn-outline-primary highlight-bar-toggle {{!collapsed ? 'open': 'collapsed'}}"
(click)="collapse.toggle()"
[attr.aria-expanded]="!collapsed"
aria-controls="highlight-bar"
>
<i class="fa-solid fa-palette" aria-hidden="true"></i>
<i class="fa-solid fa-angles-right" aria-hidden="true"></i>
</button>
}
<div #collapse="ngbCollapse" [ngbCollapse]="collapsed" (ngbCollapseChange)="updateCollapse($event)"
[horizontal]="true" style="max-width: 500px" id="highlight-bar">
<div class="highlight-bar" style="max-height: 47px">
@let activeSlot = selectedSlot();
@if (activeSlot) {
<div class="color-picker-container">
@for(slot of slots(); track slot.color; let index = $index) {
<app-setting-colour-picker
[id]="'slot-' + (index + 1)"
[label]="t('slot-label', {slot: index + 1})"
[color]="slot.color"
(colorChange)="handleSlotColourChange(index, $event)"
[selected]="slot.slotNumber === selectedSlotIndex()"
(selectPicker)="selectSlot(index, slot)"
[editMode]="isEditMode()"
(editModeChange)="isEditMode.set($event)"
[first]="$first"
[last]="$last"
/>
}
</div>
}
@if (desktopLayout()) {
<button class="btn btn-icon ms-2 color" (click)="toggleEditMode()">
@if (!isEditMode()) {
<i class="fa-solid fa-pencil-alt" aria-hidden="true"></i>
} @else {
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
}
</button>
}
</div>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,33 @@
.btn-sm {
padding: 0.25rem 0.5rem;
}
.highlight-bar {
background: var(--accordion-body-bg-color);
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
display: flex;
align-items: center;
padding: 0.5rem;
}
.highlight-bar-toggle {
background: var(--accordion-body-bg-color);
border-color: transparent;
&.open {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.highlight-bar .btn.btn-icon {
display: flex;
justify-content: center;
align-items: center;
}
.color-picker-container {
display: flex;
align-items: center;
gap: 0.25rem;
}

View File

@ -0,0 +1,65 @@
import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, model} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {HighlightSlot, RgbaColor} from "../../../_models/annotations/highlight-slot";
import {AnnotationService} from "../../../../_services/annotation.service";
import {NgbCollapse} from "@ng-bootstrap/ng-bootstrap";
import {ColorscapeService} from "../../../../_services/colorscape.service";
import {Breakpoint, UserBreakpoint, UtilityService} from "../../../../shared/_services/utility.service";
import {
SettingColorPickerComponent
} from "../../../../settings/_components/setting-colour-picker/setting-color-picker.component";
@Component({
selector: 'app-highlight-bar',
imports: [
TranslocoDirective,
NgbCollapse,
SettingColorPickerComponent
],
templateUrl: './highlight-bar.component.html',
styleUrl: './highlight-bar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HighlightBarComponent {
private readonly annotationService = inject(AnnotationService);
private readonly colorscapeService = inject(ColorscapeService);
protected readonly utilityService = inject(UtilityService);
private readonly destroyRef = inject(DestroyRef);
selectedSlotIndex = model.required<number>();
isCollapsed = model<boolean>(true);
canCollapse = model<boolean>(true);
isEditMode = model<boolean>(false);
slots = this.annotationService.slots;
selectedSlot = computed(() => {
const index = this.selectedSlotIndex();
const slots = this.annotationService.slots();
if (slots.length === 0 || index >= slots.length) return null;
return slots[index];
});
desktopLayout = computed(() => this.utilityService.activeUserBreakpoint() >= UserBreakpoint.Desktop);
selectSlot(index: number, slot: HighlightSlot) {
this.selectedSlotIndex.set(index);
}
updateCollapse(val: boolean) {
this.isCollapsed.set(val);
}
toggleEditMode() {
const existingEdit = this.isEditMode();
this.isEditMode.set(!existingEdit);
}
handleSlotColourChange(index: number, color: RgbaColor) {
this.annotationService.updateSlotColor(index, color).subscribe();
}
protected readonly Breakpoint = Breakpoint;
}

View File

@ -0,0 +1,20 @@
<ng-container *transloco="let t; prefix: 'epub-setting-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
@let sId = seriesId();
@let rp = readingProfile();
@if (sId && rp) {
<app-reader-settings
[seriesId]="sId"
[readingProfile]="rp"
[readerSettingsService]="readerSettingsService()"
></app-reader-settings>
}
</div>
</ng-container>

View File

@ -0,0 +1,6 @@
// You must add this on a component based drawer
:host {
height: 100%;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,41 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {ReaderSettingsComponent} from "../../reader-settings/reader-settings.component";
import {ReadingProfile} from "../../../../_models/preferences/reading-profiles";
import {TranslocoDirective} from "@jsverse/transloco";
import {EpubReaderSettingsService} from "../../../../_services/epub-reader-settings.service";
@Component({
selector: 'app-epub-setting-drawer',
imports: [
ReaderSettingsComponent,
TranslocoDirective
],
templateUrl: './epub-setting-drawer.component.html',
styleUrl: './epub-setting-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EpubSettingDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
chapterId = model<number>();
seriesId = model<number>();
readingProfile = model<ReadingProfile>();
readerSettingsService = model.required<EpubReaderSettingsService>();
constructor() {
effect(() => {
const id = this.chapterId();
if (!id) {
console.error('You must pass chapterId');
return;
}
});
}
close() {
this.activeOffcanvas.close();
}
}

View File

@ -0,0 +1,30 @@
<ng-container *transloco="let t; prefix: 'view-annotations-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
<form [formGroup]="formGroup">
@if (annotations.length > FilterAfter) {
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
</div>
</div>
</div>
}
@for(annotation of annotations() | filter: filterList; track annotation.comment + annotation.highlightColor + annotation.containsSpolier) {
<app-annotation-card [annotation]="annotation" (delete)="handleDelete(annotation)" (navigate)="handleNavigateTo($event)" />
}
@empty {
<p>{{t('no-data')}}</p>
}
</form>
</div>
</ng-container>

View File

@ -0,0 +1,9 @@
// You must add this on a component based drawer
:host {
height: 100%;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,53 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Output, Signal} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {AnnotationCardComponent} from "../../_annotations/annotation-card/annotation-card.component";
import {Annotation} from "../../../_models/annotations/annotation";
import {AnnotationService} from "../../../../_services/annotation.service";
import {FilterPipe} from "../../../../_pipes/filter.pipe";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-view-annotations-drawer',
imports: [
TranslocoDirective,
AnnotationCardComponent,
FilterPipe,
ReactiveFormsModule
],
templateUrl: './view-annotations-drawer.component.html',
styleUrl: './view-annotations-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViewAnnotationsDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly annotationService = inject(AnnotationService);
@Output() loadAnnotation: EventEmitter<Annotation> = new EventEmitter();
annotations: Signal<Annotation[]> = this.annotationService.annotations;
formGroup = new FormGroup({
filter: new FormControl('', [])
});
readonly FilterAfter = 6;
handleDelete(annotation: Annotation) {
this.annotationService.delete(annotation.id).subscribe();
}
handleNavigateTo(annotation: Annotation) {
this.loadAnnotation.emit(annotation);
this.close();
}
close() {
this.activeOffcanvas.close();
}
filterList = (listItem: Annotation) => {
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.pageNumber.toString().indexOf(query) >= 0
|| (listItem.selectedText ?? '').toLowerCase().indexOf(query) >= 0;
}
}

View File

@ -0,0 +1,76 @@
<ng-container *transloco="let t; prefix: 'view-bookmark-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{t('title')}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
<ul #subnav="ngbNav" ngbNav [(activeId)]="tocId" class="reader-pills nav nav-pills mb-2" [destroyOnHide]="false">
<li [ngbNavItem]="TabID.Image">
<a ngbNavLink>{{t('text-header')}}</a>
<ng-template ngbNavContent>
<app-personal-table-of-contents [chapterId]="chapterId()!" [pageNum]="pageNum()" (loadChapter)="loadChapterPart($event)"
[tocRefresh]="refreshPToC" />
</ng-template>
</li>
<li [ngbNavItem]="TabID.Text">
<a ngbNavLink>{{t('image-header')}}</a>
<ng-template ngbNavContent>
@let items = bookmarks();
<div class="px-2 py-1">
@for(item of items; track item) {
<div class="bookmark-item mb-2" role="listitem" tabindex="0">
<div class="thumb-wrapper">
<app-image
[imageUrl]="imageService.getBookmarkedImage(item.chapterId, item.page, item.imageOffset)"
[styles]="{width: '100%', height: '100%', objectFit: 'cover'}" />
</div>
<div class="bookmark-meta">
<div class="title-row">
<span class="page-label">{{t('page-num-label', {page: item.page})}}</span>
<div class="actions d-none d-sm-flex">
<button class="btn btn-success btn-icon" (click)="goToBookmark(item)" [attr.aria-label]="t('view')">
<i class="fa-solid fa-eye"></i>
</button>
<button class="btn btn-danger btn-icon" (click)="deleteBookmark(item)" [attr.aria-label]="t('delete')">
<i class="fa fa-trash-alt"></i>
</button>
</div>
</div>
@if (item.chapterTitle) {
<div class="chapter-title" [title]="item.chapterTitle">{{item.chapterTitle}}</div>
}
<div class="actions actions-mobile mt-2 d-flex d-sm-none">
<button class="btn btn-success flex-fill mr-2" (click)="goToBookmark(item)" [attr.aria-label]="t('view')">
<i class="fa-solid fa-eye"></i>
</button>
<button class="btn btn-danger flex-fill" (click)="deleteBookmark(item)" [attr.aria-label]="t('delete')">
<i class="fa fa-trash-alt"></i>
</button>
</div>
</div>
</div>
}
@empty {
<div class="text-center text-muted py-4">
<i class="fa fa-bookmark-o fa-2x mb-2"></i>
<p class="mb-0 text-white">{{t('no-data')}}</p>
</div>
}
</div>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="subnav" class="mt-3"></div>
</div>
</ng-container>

View File

@ -0,0 +1,87 @@
// You must add this on a component based drawer
:host {
height: 100%;
display: flex;
flex-direction: column;
}
.bookmark-item {
display: flex;
background: var(--bs-dark, #2b2b2b);
border-radius: .5rem;
overflow: hidden;
min-height: 92px;
box-shadow: 0 2px 4px rgba(0,0,0,.4);
outline: none;
&:focus-visible {
box-shadow: 0 0 0 3px rgba(100,150,250,.6);
}
}
.thumb-wrapper {
width: 80px;
flex: 0 0 80px;
background: #111;
position: relative;
display: flex;
align-items: center;
justify-content: center;
img, app-image {
border-radius: 0;
}
}
.bookmark-meta {
flex: 1;
display: flex;
flex-direction: column;
padding: .5rem .75rem;
min-width: 0; // allow text truncation
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .5rem;
}
.page-label {
font-weight: 600;
font-size: .9rem;
color: #fff;
white-space: nowrap;
}
.chapter-title {
font-size: .75rem;
line-height: 1.1rem;
color: #b5b5b5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: .4rem;
}
.btn-icon {
padding: .35rem .55rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
// Mobile tweaks
@media (max-width: 576px) {
.bookmark-item {
min-height: 84px;
}
.thumb-wrapper { width: 64px; flex-basis: 64px; }
.bookmark-meta { padding: .5rem .5rem .6rem; }
.chapter-title { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; }
}

Some files were not shown because too many files have changed in this diff Show More