mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-10-24 23:38:59 -04:00
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:
parent
3b883b178f
commit
b141613d60
@ -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>
|
||||
|
||||
172
API/Controllers/AnnotationController.cs
Normal file
172
API/Controllers/AnnotationController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -10,4 +10,8 @@ namespace API.Controllers;
|
||||
[Authorize]
|
||||
public class BaseApiController : ControllerBase
|
||||
{
|
||||
public BaseApiController()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
{
|
||||
|
||||
62
API/DTOs/Reader/AnnotationDto.cs
Normal file
62
API/DTOs/Reader/AnnotationDto.cs
Normal 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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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!;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
3849
API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs
generated
Normal file
3849
API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
137
API/Data/Migrations/20250820150458_BookAnnotations.cs
Normal file
137
API/Data/Migrations/20250820150458_BookAnnotations.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
49
API/Data/Repositories/AnnotationRepository.cs
Normal file
49
API/Data/Repositories/AnnotationRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
175
API/Data/Seed.cs
175
API/Data/Seed.cs
@ -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)
|
||||
{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
67
API/Entities/AppUserAnnotation.cs
Normal file
67
API/Entities/AppUserAnnotation.cs
Normal 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; }
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
11
API/Entities/Enums/HightlightColor.cs
Normal file
11
API/Entities/Enums/HightlightColor.cs
Normal 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,
|
||||
}
|
||||
20
API/Entities/HighlightSlot.cs
Normal file
20
API/Entities/HighlightSlot.cs
Normal 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; }
|
||||
}
|
||||
263
API/Helpers/AnnotationHelper.cs
Normal file
263
API/Helpers/AnnotationHelper.cs
Normal 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();
|
||||
}
|
||||
@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
86
API/Helpers/BookChapterItemHelper.cs
Normal file
86
API/Helpers/BookChapterItemHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
4767
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
37
UI/Web/src/_quill-theme.scss
Normal file
37
UI/Web/src/_quill-theme.scss
Normal 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);
|
||||
//}
|
||||
}
|
||||
47
UI/Web/src/app/_directives/long-click.directive.ts
Normal file
47
UI/Web/src/app/_directives/long-click.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,5 +4,4 @@ export interface IHasReadingTime {
|
||||
avgHoursToRead: number;
|
||||
pages: number;
|
||||
wordCount: number;
|
||||
|
||||
}
|
||||
|
||||
5
UI/Web/src/app/_models/events/annotation-update-event.ts
Normal file
5
UI/Web/src/app/_models/events/annotation-update-event.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import {Annotation} from "../../book-reader/_models/annotations/annotation";
|
||||
|
||||
export interface AnnotationUpdateEvent {
|
||||
annotation: Annotation;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@ export interface PersonalToC {
|
||||
pageNumber: number;
|
||||
title: string;
|
||||
bookScrollId: string | undefined;
|
||||
selectedText: string | null;
|
||||
chapterTitle: string | null;
|
||||
/* Ui Only */
|
||||
position: 0;
|
||||
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
18
UI/Web/src/app/_pipes/highlight-color.pipe.ts
Normal file
18
UI/Web/src/app/_pipes/highlight-color.pipe.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
21
UI/Web/src/app/_pipes/page-chapter-label.pipe.ts
Normal file
21
UI/Web/src/app/_pipes/page-chapter-label.pipe.ts
Normal 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});
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
14
UI/Web/src/app/_pipes/slot-color.pipe.ts
Normal file
14
UI/Web/src/app/_pipes/slot-color.pipe.ts
Normal 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})`;
|
||||
}
|
||||
|
||||
}
|
||||
132
UI/Web/src/app/_services/annotation.service.ts
Normal file
132
UI/Web/src/app/_services/annotation.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
? {
|
||||
|
||||
53
UI/Web/src/app/_services/epub-highlight.service.ts
Normal file
53
UI/Web/src/app/_services/epub-highlight.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
UI/Web/src/app/_services/epub-reader-menu.service.ts
Normal file
172
UI/Web/src/app/_services/epub-reader-menu.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
690
UI/Web/src/app/_services/epub-reader-settings.service.ts
Normal file
690
UI/Web/src/app/_services/epub-reader-settings.service.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
169
UI/Web/src/app/_services/layout-measurement.service.ts
Normal file
169
UI/Web/src/app/_services/layout-measurement.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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}});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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[]>();
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<span class="epub-highlight clickable" #highlightSpan>
|
||||
<span class="epub-highlight" [ngStyle]="{'background-color': highlightStyle()}" (click)="viewAnnotation()">
|
||||
<ng-content />
|
||||
</span>
|
||||
</span>
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
.icon-spacer {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.epub-highlight {
|
||||
position: relative;
|
||||
display: inline;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
@ -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, (_) => {});
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,6 @@
|
||||
// You must add this on a component based drawer
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,9 @@
|
||||
|
||||
// You must add this on a component based drawer
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user