mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-10-26 16:22:28 -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" Version="3.1.0" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.17.1" />
|
<PackageReference Include="NetVips.Native" Version="8.17.1" />
|
||||||
<PackageReference Include="Polly" Version="8.6.2" />
|
<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" Version="4.3.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.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.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.40.0" />
|
<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">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.15.0.120848">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<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]
|
[Authorize]
|
||||||
public class BaseApiController : ControllerBase
|
public class BaseApiController : ControllerBase
|
||||||
{
|
{
|
||||||
|
public BaseApiController()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
@ -34,33 +35,41 @@ public class BookController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves information for the PDF and Epub reader
|
/// Retrieves information for the PDF and Epub reader. This will cache the file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This only applies to Epub or PDF files</remarks>
|
/// <remarks>This only applies to Epub or PDF files</remarks>
|
||||||
/// <param name="chapterId"></param>
|
/// <param name="chapterId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("{chapterId}/book-info")]
|
[HttpGet("{chapterId}/book-info")]
|
||||||
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])]
|
||||||
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
|
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
|
||||||
{
|
{
|
||||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||||
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||||
var bookTitle = string.Empty;
|
var bookTitle = string.Empty;
|
||||||
|
|
||||||
|
|
||||||
switch (dto.SeriesFormat)
|
switch (dto.SeriesFormat)
|
||||||
{
|
{
|
||||||
case MangaFormat.Epub:
|
case MangaFormat.Epub:
|
||||||
{
|
{
|
||||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
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;
|
bookTitle = book.Title;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MangaFormat.Pdf:
|
case MangaFormat.Pdf:
|
||||||
{
|
{
|
||||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
||||||
|
await _cacheService.Ensure(chapterId);
|
||||||
|
var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath);
|
||||||
if (string.IsNullOrEmpty(bookTitle))
|
if (string.IsNullOrEmpty(bookTitle))
|
||||||
{
|
{
|
||||||
// Override with filename
|
// Override with filename
|
||||||
bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
|
bookTitle = Path.GetFileNameWithoutExtension(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -72,7 +81,7 @@ public class BookController : BaseApiController
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new BookInfoDto()
|
var info = new BookInfoDto()
|
||||||
{
|
{
|
||||||
ChapterNumber = dto.ChapterNumber,
|
ChapterNumber = dto.ChapterNumber,
|
||||||
VolumeNumber = dto.VolumeNumber,
|
VolumeNumber = dto.VolumeNumber,
|
||||||
@ -84,7 +93,10 @@ public class BookController : BaseApiController
|
|||||||
LibraryId = dto.LibraryId,
|
LibraryId = dto.LibraryId,
|
||||||
IsSpecial = dto.IsSpecial,
|
IsSpecial = dto.IsSpecial,
|
||||||
Pages = dto.Pages,
|
Pages = dto.Pages,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -157,7 +169,11 @@ public class BookController : BaseApiController
|
|||||||
|
|
||||||
try
|
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)
|
catch (KavitaException ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -193,13 +193,12 @@ public class ImageController : BaseApiController
|
|||||||
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
|
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("bookmark")]
|
[HttpGet("bookmark")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey", "imageOffset"])]
|
||||||
])]
|
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey, int imageOffset = 0)
|
||||||
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
|
|
||||||
{
|
{
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId == 0) return BadRequest();
|
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"));
|
if (bookmark == null) return BadRequest(await _localizationService.Translate(userId, "bookmark-doesnt-exist"));
|
||||||
|
|
||||||
var bookmarkDirectory =
|
var bookmarkDirectory =
|
||||||
|
|||||||
@ -38,7 +38,7 @@ namespace API.Controllers;
|
|||||||
#nullable enable
|
#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)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger<OpdsController> logger): ActionFilterAttribute
|
public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger<OpdsController> logger): ActionFilterAttribute
|
||||||
@ -49,14 +49,13 @@ public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationServ
|
|||||||
int userId;
|
int userId;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) ||
|
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
|
||||||
apiKeyObj is not string apiKey || context.Controller is not OpdsController controller)
|
|
||||||
{
|
{
|
||||||
context.Result = new BadRequestResult();
|
context.Result = new BadRequestResult();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
userId = await controller.GetUser(apiKey);
|
userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId == null || userId == 0)
|
if (userId == null || userId == 0)
|
||||||
{
|
{
|
||||||
context.Result = new UnauthorizedResult();
|
context.Result = new UnauthorizedResult();
|
||||||
@ -1346,7 +1345,7 @@ public class OpdsController : BaseApiController
|
|||||||
/// Gets the user from the API key
|
/// Gets the user from the API key
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<int> GetUser(string apiKey)
|
private async Task<int> GetUser(string apiKey)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
@ -15,6 +16,8 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
|
using API.Services.Tasks.Metadata;
|
||||||
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -41,6 +44,7 @@ public class ReaderController : BaseApiController
|
|||||||
private readonly IEventHub _eventHub;
|
private readonly IEventHub _eventHub;
|
||||||
private readonly IScrobblingService _scrobblingService;
|
private readonly IScrobblingService _scrobblingService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IBookService _bookService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ReaderController(ICacheService cacheService,
|
public ReaderController(ICacheService cacheService,
|
||||||
@ -48,7 +52,8 @@ public class ReaderController : BaseApiController
|
|||||||
IReaderService readerService, IBookmarkService bookmarkService,
|
IReaderService readerService, IBookmarkService bookmarkService,
|
||||||
IAccountService accountService, IEventHub eventHub,
|
IAccountService accountService, IEventHub eventHub,
|
||||||
IScrobblingService scrobblingService,
|
IScrobblingService scrobblingService,
|
||||||
ILocalizationService localizationService)
|
ILocalizationService localizationService,
|
||||||
|
IBookService bookService)
|
||||||
{
|
{
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
@ -59,6 +64,7 @@ public class ReaderController : BaseApiController
|
|||||||
_eventHub = eventHub;
|
_eventHub = eventHub;
|
||||||
_scrobblingService = scrobblingService;
|
_scrobblingService = scrobblingService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_bookService = bookService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <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="chapterId"></param>
|
||||||
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
|
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
|
||||||
/// <param name="includeDimensions">Include file dimensions. Only useful for image based reading</param>
|
/// <param name="includeDimensions">Include file dimensions. Only useful for image-based reading</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("chapter-info")]
|
[HttpGet("chapter-info")]
|
||||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])]
|
||||||
])]
|
|
||||||
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
|
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
|
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
|
||||||
@ -719,26 +724,63 @@ public class ReaderController : BaseApiController
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("bookmark")]
|
[HttpPost("bookmark")]
|
||||||
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
|
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
// Don't let user save past total pages.
|
// Don't let user save past total pages.
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
|
||||||
|
AppUserIncludes.Bookmarks);
|
||||||
if (user == null) return new UnauthorizedResult();
|
if (user == null) return new UnauthorizedResult();
|
||||||
|
|
||||||
if (!await _accountService.HasBookmarkPermission(user))
|
if (!await _accountService.HasBookmarkPermission(user))
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
|
||||||
|
|
||||||
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
|
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
|
||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
if (chapter == null || chapter.Files.Count == 0)
|
||||||
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
||||||
|
|
||||||
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
||||||
var path = _cacheService.GetCachedPagePath(chapter.Id, 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"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
|
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
|
||||||
return Ok();
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a bookmarked page for a Chapter
|
/// Removes a bookmarked page for a Chapter
|
||||||
@ -826,6 +868,48 @@ public class ReaderController : BaseApiController
|
|||||||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
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>
|
/// <summary>
|
||||||
/// Returns the user's personal table of contents for the given chapter
|
/// Returns the user's personal table of contents for the given chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -879,6 +963,12 @@ public class ReaderController : BaseApiController
|
|||||||
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
|
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()
|
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
|
||||||
{
|
{
|
||||||
Title = dto.Title.Trim(),
|
Title = dto.Title.Trim(),
|
||||||
@ -887,12 +977,16 @@ public class ReaderController : BaseApiController
|
|||||||
SeriesId = dto.SeriesId,
|
SeriesId = dto.SeriesId,
|
||||||
LibraryId = dto.LibraryId,
|
LibraryId = dto.LibraryId,
|
||||||
BookScrollId = dto.BookScrollId,
|
BookScrollId = dto.BookScrollId,
|
||||||
|
SelectedText = dto.SelectedText,
|
||||||
|
ChapterTitle = chapterTitle,
|
||||||
AppUserId = userId
|
AppUserId = userId
|
||||||
});
|
});
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all progress events for a given chapter
|
/// Get all progress events for a given chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -905,4 +999,5 @@ public class ReaderController : BaseApiController
|
|||||||
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId));
|
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));
|
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")]
|
[Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")]
|
||||||
[HttpGet("chapter-metadata")]
|
[HttpGet("chapter-metadata")]
|
||||||
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
|
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
|
||||||
|
|||||||
@ -109,6 +109,7 @@ public class UsersController : BaseApiController
|
|||||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||||
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
||||||
|
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
|
||||||
|
|
||||||
if (await _licenseService.HasActiveLicense())
|
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]
|
[Required]
|
||||||
public int ChapterId { get; set; }
|
public int ChapterId { get; set; }
|
||||||
/// <summary>
|
/// <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.
|
/// This is only used when getting all bookmarks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SeriesDto? Series { get; set; }
|
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 int PageNumber { get; set; }
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public string? BookScrollId { get; set; }
|
public string? BookScrollId { get; set; }
|
||||||
|
public string? SelectedText { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,27 @@
|
|||||||
|
|
||||||
public sealed record PersonalToCDto
|
public sealed record PersonalToCDto
|
||||||
{
|
{
|
||||||
|
public required int Id { get; init; }
|
||||||
public required int ChapterId { get; set; }
|
public required int ChapterId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The page to bookmark
|
||||||
|
/// </summary>
|
||||||
public required int PageNumber { get; set; }
|
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; }
|
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; }
|
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<MangaFileDto> Files { get; set; } = default!;
|
||||||
public IEnumerable<ChapterDto> Chapters { get; set; } = default!;
|
public IEnumerable<ChapterDto> Chapters { get; set; } = default!;
|
||||||
public IEnumerable<BookmarkSearchResultDto> Bookmarks { 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.DTOs.Theme;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
@ -41,4 +42,7 @@ public sealed record UserPreferencesDto
|
|||||||
public bool AniListScrobblingEnabled { get; set; }
|
public bool AniListScrobblingEnabled { get; set; }
|
||||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.WantToReadSync"/>
|
/// <inheritdoc cref="API.Entities.AppUserPreferences.WantToReadSync"/>
|
||||||
public bool WantToReadSync { get; set; }
|
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<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||||
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
||||||
|
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@ -301,6 +302,14 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasDefaultValue(new List<MetadataSettingField>());
|
.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>()
|
builder.Entity<AppUser>()
|
||||||
.Property(user => user.IdentityProvider)
|
.Property(user => user.IdentityProvider)
|
||||||
.HasDefaultValue(IdentityProvider.Kavita);
|
.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);
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@ -174,6 +246,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("ChapterId")
|
b.Property<int>("ChapterId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterTitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTime>("Created")
|
b.Property<DateTime>("Created")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@ -183,6 +258,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ImageOffset")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime>("LastModified")
|
b.Property<DateTime>("LastModified")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@ -198,6 +276,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("VolumeId")
|
b.Property<int>("VolumeId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("XPath")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
@ -434,6 +515,11 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("BookReaderFontSize")
|
b.Property<int>("BookReaderFontSize")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BookReaderHighlightSlots")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
b.Property<bool>("BookReaderImmersiveMode")
|
b.Property<bool>("BookReaderImmersiveMode")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@ -834,6 +920,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("ChapterId")
|
b.Property<int>("ChapterId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChapterTitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTime>("Created")
|
b.Property<DateTime>("Created")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@ -852,6 +941,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("PageNumber")
|
b.Property<int>("PageNumber")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedText")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("SeriesId")
|
b.Property<int>("SeriesId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@ -2834,6 +2926,25 @@ namespace API.Data.Migrations
|
|||||||
b.ToTable("SeriesMetadataTag");
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
@ -3620,6 +3731,8 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUser", b =>
|
modelBuilder.Entity("API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Annotations");
|
||||||
|
|
||||||
b.Navigation("Bookmarks");
|
b.Navigation("Bookmarks");
|
||||||
|
|
||||||
b.Navigation("ChapterRatings");
|
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()
|
.Select(data => new ChapterInfoDto()
|
||||||
{
|
{
|
||||||
ChapterNumber = data.ChapterNumber + string.Empty, // TODO: Fix this
|
ChapterNumber = data.ChapterNumber + string.Empty,
|
||||||
VolumeNumber = data.VolumeNumber + string.Empty, // TODO: Fix this
|
VolumeNumber = data.VolumeNumber + string.Empty,
|
||||||
VolumeId = data.VolumeId,
|
VolumeId = data.VolumeId,
|
||||||
IsSpecial = data.IsSpecial,
|
IsSpecial = data.IsSpecial,
|
||||||
SeriesId = data.SeriesId,
|
SeriesId = data.SeriesId,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ using API.DTOs.Filtering.v2;
|
|||||||
using API.DTOs.KavitaPlus.Metadata;
|
using API.DTOs.KavitaPlus.Metadata;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.Person;
|
using API.DTOs.Person;
|
||||||
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
@ -402,6 +403,14 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.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 justYear = _yearRegex.Match(searchQuery).Value;
|
||||||
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
||||||
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
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>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
|
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
|
||||||
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
|
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<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
|
||||||
Task<int> GetUserIdByApiKeyAsync(string apiKey);
|
Task<int> GetUserIdByApiKeyAsync(string apiKey);
|
||||||
Task<AppUser?> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
|
Task<AppUser?> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||||
@ -107,6 +107,8 @@ public interface IUserRepository
|
|||||||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
||||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
||||||
|
Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId);
|
||||||
|
Task<List<AnnotationDto>> GetAnnotationsByPage(int userId, int chapterId, int pageNum);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Try getting a user by the id provided by OIDC
|
/// Try getting a user by the id provided by OIDC
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -114,6 +116,8 @@ public interface IUserRepository
|
|||||||
/// <param name="includes"></param>
|
/// <param name="includes"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
|
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
|
||||||
|
|
||||||
|
Task<AnnotationDto?> GetAnnotationDtoById(int userId, int annotationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserRepository : IUserRepository
|
public class UserRepository : IUserRepository
|
||||||
@ -228,18 +232,18 @@ public class UserRepository : IUserRepository
|
|||||||
return await _context.AppUserBookmark.ToListAsync();
|
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
|
return await _context.AppUserBookmark
|
||||||
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId)
|
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId && b.ImageOffset == imageOffset)
|
||||||
.SingleOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId)
|
public async Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId)
|
||||||
{
|
{
|
||||||
return await _context.AppUserBookmark
|
return await _context.AppUserBookmark
|
||||||
.Where(b => b.Id == bookmarkId)
|
.Where(b => b.Id == bookmarkId)
|
||||||
.SingleOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -557,13 +561,39 @@ public class UserRepository : IUserRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deviceEmail"></param>
|
/// <param name="deviceEmail"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
|
public async Task<AppUser?> GetUserByDeviceEmail(string deviceEmail)
|
||||||
{
|
{
|
||||||
return await _context.AppUser
|
return await _context.AppUser
|
||||||
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
|
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
|
||||||
.FirstOrDefaultAsync();
|
.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)
|
public async Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(oidcId)) return null;
|
if (string.IsNullOrEmpty(oidcId)) return null;
|
||||||
@ -574,6 +604,14 @@ public class UserRepository : IUserRepository
|
|||||||
.FirstOrDefaultAsync();
|
.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()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository
|
|||||||
void Remove(AppUserTableOfContent toc);
|
void Remove(AppUserTableOfContent toc);
|
||||||
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
|
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
|
||||||
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
|
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);
|
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository
|
|||||||
.AsEnumerable();
|
.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)
|
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
|
||||||
{
|
{
|
||||||
return await _context.AppUserTableOfContent
|
return await _context.AppUserTableOfContent
|
||||||
|
|||||||
@ -30,6 +30,45 @@ public static class Seed
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static ImmutableArray<ServerSetting> DefaultSettings;
|
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 = [
|
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
|
||||||
..new List<SiteTheme>
|
..new List<SiteTheme>
|
||||||
{
|
{
|
||||||
@ -45,8 +84,8 @@ public static class Seed
|
|||||||
}.ToArray()
|
}.ToArray()
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
|
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = [
|
||||||
new List<AppUserDashboardStream>
|
..new List<AppUserDashboardStream>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@ -80,38 +119,40 @@ public static class Seed
|
|||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = false
|
Visible = false
|
||||||
},
|
},
|
||||||
}.ToArray());
|
}.ToArray()
|
||||||
|
];
|
||||||
|
|
||||||
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams = ImmutableArray.Create(
|
public static readonly ImmutableArray<AppUserSideNavStream> DefaultSideNavStreams =
|
||||||
new AppUserSideNavStream()
|
[
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
Name = "want-to-read",
|
Name = "want-to-read",
|
||||||
StreamType = SideNavStreamType.WantToRead,
|
StreamType = SideNavStreamType.WantToRead,
|
||||||
Order = 1,
|
Order = 1,
|
||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
}, new AppUserSideNavStream()
|
}, new()
|
||||||
{
|
{
|
||||||
Name = "collections",
|
Name = "collections",
|
||||||
StreamType = SideNavStreamType.Collections,
|
StreamType = SideNavStreamType.Collections,
|
||||||
Order = 2,
|
Order = 2,
|
||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
}, new AppUserSideNavStream()
|
}, new()
|
||||||
{
|
{
|
||||||
Name = "reading-lists",
|
Name = "reading-lists",
|
||||||
StreamType = SideNavStreamType.ReadingLists,
|
StreamType = SideNavStreamType.ReadingLists,
|
||||||
Order = 3,
|
Order = 3,
|
||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
}, new AppUserSideNavStream()
|
}, new()
|
||||||
{
|
{
|
||||||
Name = "bookmarks",
|
Name = "bookmarks",
|
||||||
StreamType = SideNavStreamType.Bookmarks,
|
StreamType = SideNavStreamType.Bookmarks,
|
||||||
Order = 4,
|
Order = 4,
|
||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
}, new AppUserSideNavStream()
|
}, new()
|
||||||
{
|
{
|
||||||
Name = "all-series",
|
Name = "all-series",
|
||||||
StreamType = SideNavStreamType.AllSeries,
|
StreamType = SideNavStreamType.AllSeries,
|
||||||
@ -119,14 +160,15 @@ public static class Seed
|
|||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
},
|
},
|
||||||
new AppUserSideNavStream()
|
new()
|
||||||
{
|
{
|
||||||
Name = "browse-authors",
|
Name = "browse-authors",
|
||||||
StreamType = SideNavStreamType.BrowsePeople,
|
StreamType = SideNavStreamType.BrowsePeople,
|
||||||
Order = 6,
|
Order = 6,
|
||||||
IsProvided = true,
|
IsProvided = true,
|
||||||
Visible = true
|
Visible = true
|
||||||
});
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
|
||||||
@ -215,10 +257,24 @@ 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)
|
public static async Task SeedSettings(DataContext context, IDirectoryService directoryService)
|
||||||
{
|
{
|
||||||
await context.Database.EnsureCreatedAsync();
|
await context.Database.EnsureCreatedAsync();
|
||||||
DefaultSettings = ImmutableArray.Create(new List<ServerSetting>()
|
DefaultSettings = [
|
||||||
|
..new List<ServerSetting>()
|
||||||
{
|
{
|
||||||
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
|
new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
|
||||||
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
new() {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
||||||
@ -267,7 +323,8 @@ public static class Seed
|
|||||||
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
|
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
|
||||||
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
|
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
|
||||||
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)},
|
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)},
|
||||||
}.ToArray());
|
}.ToArray()
|
||||||
|
];
|
||||||
|
|
||||||
foreach (var defaultSetting in DefaultSettings)
|
foreach (var defaultSetting in DefaultSettings)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -34,6 +34,7 @@ public interface IUnitOfWork
|
|||||||
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
||||||
IEmailHistoryRepository EmailHistoryRepository { get; }
|
IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||||
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||||
|
IAnnotationRepository AnnotationRepository { get; }
|
||||||
bool Commit();
|
bool Commit();
|
||||||
Task<bool> CommitAsync();
|
Task<bool> CommitAsync();
|
||||||
bool HasChanges();
|
bool HasChanges();
|
||||||
@ -76,6 +77,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper);
|
ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper);
|
||||||
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
|
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
|
||||||
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
|
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
|
||||||
|
AnnotationRepository = new AnnotationRepository(_context, _mapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -106,6 +108,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
||||||
public IEmailHistoryRepository EmailHistoryRepository { get; }
|
public IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||||
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||||
|
public IAnnotationRepository AnnotationRepository { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commits changes to the DB. Completes the open transaction.
|
/// 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
|
/// A list of Table of Contents for a given Chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
||||||
|
public ICollection<AppUserAnnotation> Annotations { get; set; } = null!;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An API Key to interact with external services, like OPDS
|
/// An API Key to interact with external services, like OPDS
|
||||||
/// </summary>
|
/// </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;
|
namespace API.Entities;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a saved page in a Chapter entity for a given user.
|
/// Represents a saved page in a Chapter entity for a given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -19,7 +20,19 @@ public class AppUserBookmark : IEntityDate
|
|||||||
/// Filename in the Bookmark Directory
|
/// Filename in the Bookmark Directory
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string FileName { get; set; } = string.Empty;
|
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
|
// Relationships
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
|||||||
@ -108,6 +108,10 @@ public class AppUserPreferences
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Defaults to false</remarks>
|
/// <remarks>Defaults to false</remarks>
|
||||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
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
|
#endregion
|
||||||
|
|
||||||
#region PdfReader
|
#region PdfReader
|
||||||
|
|||||||
@ -18,6 +18,19 @@ public class AppUserTableOfContent : IEntityDate
|
|||||||
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Title { get; set; }
|
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 required int SeriesId { get; set; }
|
||||||
public virtual Series Series { get; set; }
|
public virtual Series Series { get; set; }
|
||||||
@ -27,10 +40,7 @@ public class AppUserTableOfContent : IEntityDate
|
|||||||
|
|
||||||
public int VolumeId { get; set; }
|
public int VolumeId { get; set; }
|
||||||
public int LibraryId { 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 Created { get; set; }
|
||||||
public DateTime CreatedUtc { 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.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
||||||
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
.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>();
|
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",
|
"generic-reading-list-create": "There was an issue creating the reading list",
|
||||||
"reading-list-doesnt-exist": "Reading list does not exist",
|
"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",
|
"series-restricted": "User does not have access to this Series",
|
||||||
|
|
||||||
"generic-scrobble-hold": "An error occurred while adding the hold",
|
"generic-scrobble-hold": "An error occurred while adding the hold",
|
||||||
|
|||||||
@ -128,6 +128,7 @@ public class Program
|
|||||||
await Seed.SeedDefaultSideNavStreams(unitOfWork);
|
await Seed.SeedDefaultSideNavStreams(unitOfWork);
|
||||||
await Seed.SeedUserApiKeys(context);
|
await Seed.SeedUserApiKeys(context);
|
||||||
await Seed.SeedMetadataSettings(context);
|
await Seed.SeedMetadataSettings(context);
|
||||||
|
await Seed.SeedDefaultHighlightSlots(unitOfWork);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -7,6 +7,7 @@ using System.Text;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
|
using System.Xml.XPath;
|
||||||
using API.Data.Metadata;
|
using API.Data.Metadata;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
@ -14,6 +15,7 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
|
using API.Services.Tasks.Metadata;
|
||||||
using Docnet.Core;
|
using Docnet.Core;
|
||||||
using Docnet.Core.Converters;
|
using Docnet.Core.Converters;
|
||||||
using Docnet.Core.Models;
|
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>
|
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||||
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
|
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<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 ILogger<BookService> _logger;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
@ -129,38 +133,25 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
private static bool HasClickableHrefPart(HtmlNode anchor)
|
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("tabindex", string.Empty) != "-1"
|
||||||
&& anchor.GetAttributeValue("role", string.Empty) != "presentation";
|
&& anchor.GetAttributeValue("role", string.Empty) != "presentation";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetContentType(EpubContentType type)
|
public static string GetContentType(EpubContentType type)
|
||||||
{
|
{
|
||||||
string contentType;
|
var contentType = type switch
|
||||||
switch (type)
|
|
||||||
{
|
{
|
||||||
case EpubContentType.IMAGE_GIF:
|
EpubContentType.IMAGE_GIF => "image/gif",
|
||||||
contentType = "image/gif";
|
EpubContentType.IMAGE_PNG => "image/png",
|
||||||
break;
|
EpubContentType.IMAGE_JPEG => "image/jpeg",
|
||||||
case EpubContentType.IMAGE_PNG:
|
EpubContentType.FONT_OPENTYPE => "font/otf",
|
||||||
contentType = "image/png";
|
EpubContentType.FONT_TRUETYPE => "font/ttf",
|
||||||
break;
|
EpubContentType.IMAGE_SVG => "image/svg+xml",
|
||||||
case EpubContentType.IMAGE_JPEG:
|
_ => "application/octet-stream"
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentType;
|
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
|
// Some keys get uri encoded when parsed, so replace any of those characters with original
|
||||||
var mappingKey = Uri.UnescapeDataString(hrefParts[0]);
|
var mappingKey = Uri.UnescapeDataString(hrefParts[0]);
|
||||||
|
|
||||||
if (!mappings.ContainsKey(mappingKey))
|
if (!mappings.TryGetValue(mappingKey, out var mappedPage))
|
||||||
{
|
{
|
||||||
if (HasClickableHrefPart(anchor))
|
if (HasClickableHrefPart(anchor))
|
||||||
{
|
{
|
||||||
@ -188,7 +179,6 @@ public class BookService : IBookService
|
|||||||
mappings.TryGetValue(pageKey, out currentPage);
|
mappings.TryGetValue(pageKey, out currentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
anchor.Attributes.Add("kavita-page", $"{currentPage}");
|
anchor.Attributes.Add("kavita-page", $"{currentPage}");
|
||||||
anchor.Attributes.Add("kavita-part", part);
|
anchor.Attributes.Add("kavita-part", part);
|
||||||
anchor.Attributes.Remove("href");
|
anchor.Attributes.Remove("href");
|
||||||
@ -203,7 +193,6 @@ public class BookService : IBookService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mappedPage = mappings[mappingKey];
|
|
||||||
anchor.Attributes.Add("kavita-page", $"{mappedPage}");
|
anchor.Attributes.Add("kavita-page", $"{mappedPage}");
|
||||||
if (hrefParts.Length > 1)
|
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)
|
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
|
||||||
{
|
{
|
||||||
var images = doc.DocumentNode.SelectNodes("//img")
|
ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//img"));
|
||||||
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
|
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;
|
if (images == null) return;
|
||||||
|
|
||||||
var parent = images[0].ParentNode;
|
var parent = images[0].ParentNode;
|
||||||
@ -362,6 +389,22 @@ public class BookService : IBookService
|
|||||||
parent.AddClass("kavita-scale-width-container");
|
parent.AddClass("kavita-scale-width-container");
|
||||||
image.AddClass("kavita-scale-width");
|
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)
|
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
|
||||||
{
|
{
|
||||||
|
// TODO: Refactor this to use the Async version
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||||
@ -871,6 +915,154 @@ public class BookService : IBookService
|
|||||||
return dict;
|
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>
|
/// <summary>
|
||||||
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
/// 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
|
/// 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="body">Body element from the epub</param>
|
||||||
/// <param name="mappings">Epub mappings</param>
|
/// <param name="mappings">Epub mappings</param>
|
||||||
/// <param name="page">Page number we are loading</param>
|
/// <param name="page">Page number we are loading</param>
|
||||||
|
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
|
||||||
/// <returns></returns>
|
/// <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);
|
await InlineStyles(doc, book, apiBase, body);
|
||||||
|
|
||||||
RewriteAnchors(page, doc, mappings);
|
RewriteAnchors(page, doc, mappings);
|
||||||
|
|
||||||
|
// TODO: Pass bookmarks here for state management
|
||||||
ScopeImages(doc, book, apiBase);
|
ScopeImages(doc, book, apiBase);
|
||||||
|
|
||||||
|
InjectImages(doc, book, apiBase);
|
||||||
|
|
||||||
|
// Inject PTOC Bookmark Icons
|
||||||
|
InjectPTOCBookmarks(doc, ptocBookmarks);
|
||||||
|
|
||||||
|
// Inject Annotations
|
||||||
|
InjectAnnotations(doc, annotations);
|
||||||
|
|
||||||
|
|
||||||
return PrepareFinalHtml(doc, body);
|
return PrepareFinalHtml(doc, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1097,53 +1301,29 @@ public class BookService : IBookService
|
|||||||
{
|
{
|
||||||
foreach (var navigationItem in navItems)
|
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);
|
chaptersList.Add(tocItem);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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)
|
// 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)
|
var tocPage = book.Content.Html.Local.Select(s => s.Key)
|
||||||
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||||
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
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;
|
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||||
var content = await file.ReadContentAsync();
|
var content = await file.ReadContentAsync();
|
||||||
|
|
||||||
var doc = new HtmlDocument();
|
var doc = new HtmlDocument();
|
||||||
doc.LoadHtml(content);
|
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");
|
var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||||
if (anchors == null) return chaptersList;
|
if (anchors == null) return chaptersList;
|
||||||
|
|
||||||
@ -1164,19 +1344,56 @@ public class BookService : IBookService
|
|||||||
Title = anchor.InnerText,
|
Title = anchor.InnerText,
|
||||||
Page = mappings[key],
|
Page = mappings[key],
|
||||||
Part = part,
|
Part = part,
|
||||||
Children = new List<BookChapterItem>()
|
Children = []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return chaptersList;
|
return chaptersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountParentDirectory(string path)
|
private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
|
||||||
{
|
{
|
||||||
const string pattern = @"\.\./";
|
// Get the page mapping for the current navigation item
|
||||||
var matches = Regex.Matches(path, pattern);
|
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>
|
/// <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>
|
/// <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>
|
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
|
||||||
/// <exception cref="KavitaException">All exceptions throw this</exception>
|
/// <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);
|
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
|
||||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||||
@ -1255,7 +1473,7 @@ public class BookService : IBookService
|
|||||||
body = doc.DocumentNode.SelectSingleNode("/html/body");
|
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)
|
} catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -1267,37 +1485,37 @@ public class BookService : IBookService
|
|||||||
throw new KavitaException("epub-html-missing");
|
throw new KavitaException("epub-html-missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
|
// private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
|
||||||
ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
|
// ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
|
||||||
{
|
// {
|
||||||
if (navigationItem.Link == null)
|
// if (navigationItem.Link == null)
|
||||||
{
|
// {
|
||||||
var item = new BookChapterItem
|
// var item = new BookChapterItem
|
||||||
{
|
// {
|
||||||
Title = navigationItem.Title,
|
// Title = navigationItem.Title,
|
||||||
Children = nestedChapters
|
// Children = nestedChapters
|
||||||
};
|
// };
|
||||||
if (nestedChapters.Count > 0)
|
// if (nestedChapters.Count > 0)
|
||||||
{
|
// {
|
||||||
item.Page = nestedChapters[0].Page;
|
// item.Page = nestedChapters[0].Page;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
chaptersList.Add(item);
|
// chaptersList.Add(item);
|
||||||
}
|
// }
|
||||||
else
|
// else
|
||||||
{
|
// {
|
||||||
var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
|
// var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
|
||||||
if (mappings.ContainsKey(groupKey))
|
// if (mappings.ContainsKey(groupKey))
|
||||||
{
|
// {
|
||||||
chaptersList.Add(new BookChapterItem
|
// chaptersList.Add(new BookChapterItem
|
||||||
{
|
// {
|
||||||
Title = navigationItem.Title,
|
// Title = navigationItem.Title,
|
||||||
Page = mappings[groupKey],
|
// Page = mappings[groupKey],
|
||||||
Children = nestedChapters
|
// Children = nestedChapters
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -1341,6 +1559,28 @@ public class BookService : IBookService
|
|||||||
return string.Empty;
|
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)
|
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);
|
_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.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService
|
|||||||
/// <param name="bookmarkDto"></param>
|
/// <param name="bookmarkDto"></param>
|
||||||
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</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>
|
/// <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
|
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)
|
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);
|
_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,
|
SeriesId = bookmarkDto.SeriesId,
|
||||||
ChapterId = bookmarkDto.ChapterId,
|
ChapterId = bookmarkDto.ChapterId,
|
||||||
FileName = Path.Join(targetFolderStem, fileInfo.Name),
|
FileName = Path.Join(targetFolderStem, fileInfo.Name),
|
||||||
|
ImageOffset = bookmarkDto.ImageOffset,
|
||||||
|
XPath = bookmarkDto.XPath,
|
||||||
|
ChapterTitle = bookmarkDto.ChapterTitle,
|
||||||
AppUserId = userWithBookmarks.Id
|
AppUserId = userWithBookmarks.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,7 +179,7 @@ public class BookmarkService : IBookmarkService
|
|||||||
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
|
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
|
||||||
{
|
{
|
||||||
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
|
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
|
try
|
||||||
{
|
{
|
||||||
if (bookmarkToDelete != null)
|
if (bookmarkToDelete != null)
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public interface ICacheService
|
|||||||
IEnumerable<FileDimensionDto> GetCachedFileDimensions(string cachePath);
|
IEnumerable<FileDimensionDto> GetCachedFileDimensions(string cachePath);
|
||||||
string GetCachedBookmarkPagePath(int seriesId, int page);
|
string GetCachedBookmarkPagePath(int seriesId, int page);
|
||||||
string GetCachedFile(Chapter chapter);
|
string GetCachedFile(Chapter chapter);
|
||||||
|
string GetCachedFile(int chapterId, string firstFilePath);
|
||||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files, bool extractPdfImages = false);
|
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files, bool extractPdfImages = false);
|
||||||
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
||||||
void CleanupBookmarkCache(int seriesId);
|
void CleanupBookmarkCache(int seriesId);
|
||||||
@ -155,6 +156,17 @@ public class CacheService : ICacheService
|
|||||||
return path;
|
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>
|
/// <summary>
|
||||||
/// Caches the files for the given chapter to CacheDirectory
|
/// Caches the files for the given chapter to CacheDirectory
|
||||||
@ -342,9 +354,7 @@ public class CacheService : ICacheService
|
|||||||
// Calculate what chapter the page belongs to
|
// Calculate what chapter the page belongs to
|
||||||
var path = GetCachePath(chapterId);
|
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
|
// 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)
|
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions);
|
||||||
//.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
return GetPageFromFiles(files, page);
|
return GetPageFromFiles(files, page);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -423,11 +423,7 @@ public class EmailService : IEmailService
|
|||||||
smtpClient.Timeout = 20000;
|
smtpClient.Timeout = 20000;
|
||||||
var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None;
|
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;
|
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
|
||||||
|
|
||||||
@ -445,6 +441,12 @@ public class EmailService : IEmailService
|
|||||||
|
|
||||||
try
|
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);
|
await smtpClient.SendAsync(email);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -23,6 +23,8 @@ public interface ILocalizationService
|
|||||||
|
|
||||||
public class LocalizationService : ILocalizationService
|
public class LocalizationService : ILocalizationService
|
||||||
{
|
{
|
||||||
|
private const string LocaleCacheKey = "locales";
|
||||||
|
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@ -33,6 +35,7 @@ public class LocalizationService : ILocalizationService
|
|||||||
private readonly string _localizationDirectoryUi;
|
private readonly string _localizationDirectoryUi;
|
||||||
|
|
||||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||||
|
private readonly MemoryCacheEntryOptions _localsCacheOptions;
|
||||||
|
|
||||||
|
|
||||||
public LocalizationService(IDirectoryService directoryService,
|
public LocalizationService(IDirectoryService directoryService,
|
||||||
@ -62,6 +65,10 @@ public class LocalizationService : ILocalizationService
|
|||||||
_cacheOptions = new MemoryCacheEntryOptions()
|
_cacheOptions = new MemoryCacheEntryOptions()
|
||||||
.SetSize(1)
|
.SetSize(1)
|
||||||
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
|
.SetAbsoluteExpiration(TimeSpan.FromMinutes(15));
|
||||||
|
|
||||||
|
_localsCacheOptions = new MemoryCacheEntryOptions()
|
||||||
|
.SetSize(1)
|
||||||
|
.SetAbsoluteExpiration(TimeSpan.FromHours(24));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -139,6 +146,11 @@ public class LocalizationService : ILocalizationService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public IEnumerable<KavitaLocale> GetLocales()
|
public IEnumerable<KavitaLocale> GetLocales()
|
||||||
{
|
{
|
||||||
|
if (_cache.TryGetValue(LocaleCacheKey, out List<KavitaLocale>? cachedLocales) && cachedLocales != null)
|
||||||
|
{
|
||||||
|
return cachedLocales;
|
||||||
|
}
|
||||||
|
|
||||||
var uiLanguages = _directoryService
|
var uiLanguages = _directoryService
|
||||||
.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json");
|
.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json");
|
||||||
var backendLanguages = _directoryService
|
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
|
// Helper methods that would need to be implemented
|
||||||
|
|||||||
@ -356,7 +356,7 @@ public class ReaderService : IReaderService
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
|
private static int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
|
||||||
{
|
{
|
||||||
if (volume.IsSpecial())
|
if (volume.IsSpecial())
|
||||||
{
|
{
|
||||||
|
|||||||
@ -35,7 +35,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
private readonly IReaderService _readerService;
|
private readonly IReaderService _readerService;
|
||||||
private readonly IMediaErrorService _mediaErrorService;
|
private readonly IMediaErrorService _mediaErrorService;
|
||||||
|
|
||||||
private const int AverageCharactersPerWord = 5;
|
public const int AverageCharactersPerWord = 5;
|
||||||
|
|
||||||
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
||||||
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
|
ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
|
||||||
@ -247,7 +247,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
_unitOfWork.MangaFileRepository.Update(file);
|
_unitOfWork.MangaFileRepository.Update(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
|
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -256,7 +255,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
doc.LoadHtml(await bookFile.ReadContentAsync());
|
doc.LoadHtml(await bookFile.ReadContentAsync());
|
||||||
|
|
||||||
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
|
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
|
||||||
return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0;
|
var characterCount = textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0;
|
||||||
|
return GetWordCount(characterCount);
|
||||||
}
|
}
|
||||||
catch (EpubContentException ex)
|
catch (EpubContentException ex)
|
||||||
{
|
{
|
||||||
@ -267,4 +267,10 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int GetWordCount(int characterCount)
|
||||||
|
{
|
||||||
|
if (characterCount == 0) return 0;
|
||||||
|
return characterCount / AverageCharactersPerWord;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.Update;
|
using API.DTOs.Update;
|
||||||
using API.Entities.Person;
|
using API.Entities.Person;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
@ -156,6 +157,12 @@ public static class MessageFactory
|
|||||||
/// A Rate limit error was hit when matching a series with Kavita+
|
/// A Rate limit error was hit when matching a series with Kavita+
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError";
|
public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError";
|
||||||
|
/// <summary>
|
||||||
|
/// Annotation is updated within the reader
|
||||||
|
/// </summary>
|
||||||
|
public const string AnnotationUpdate = "AnnotationUpdate";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static SignalRMessage DashboardUpdateEvent(int userId)
|
public static SignalRMessage DashboardUpdateEvent(int userId)
|
||||||
{
|
{
|
||||||
@ -683,6 +690,7 @@ public static class MessageFactory
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName)
|
public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName)
|
||||||
{
|
{
|
||||||
return new SignalRMessage()
|
return new SignalRMessage()
|
||||||
@ -690,8 +698,20 @@ public static class MessageFactory
|
|||||||
Name = ExternalMatchRateLimitError,
|
Name = ExternalMatchRateLimitError,
|
||||||
Body = new
|
Body = new
|
||||||
{
|
{
|
||||||
seriesId = seriesId,
|
seriesId,
|
||||||
seriesName = seriesName,
|
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
|
// v0.8.8
|
||||||
await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger);
|
await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger);
|
||||||
|
await ManualMigrateBookReadingProgress.Migrate(dataContext, unitOfWork, logger);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -421,6 +422,11 @@ public class Startup
|
|||||||
opts.IncludeQueryInRequestPath = true;
|
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) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
context.Response.Headers[HeaderNames.Vary] =
|
context.Response.Headers[HeaderNames.Vary] =
|
||||||
@ -433,11 +439,7 @@ public class Startup
|
|||||||
context.Response.Headers.XFrameOptions = "SAMEORIGIN";
|
context.Response.Headers.XFrameOptions = "SAMEORIGIN";
|
||||||
|
|
||||||
// Setup CSP to ensure we load assets only from these origins
|
// Setup CSP to ensure we load assets only from these origins
|
||||||
context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';");
|
context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none';";
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger.LogCritical("appsetting.json has allow iframing on! This may allow for clickjacking on the server. User beware");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
@ -4,5 +4,12 @@
|
|||||||
"IpAddresses": "",
|
"IpAddresses": "",
|
||||||
"BaseUrl": "/",
|
"BaseUrl": "/",
|
||||||
"Cache": 75,
|
"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());
|
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||||
|
|
||||||
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
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 readonly string StatsApiUrl = "https://stats.kavitareader.com";
|
||||||
|
|
||||||
public static int Port
|
public static int Port
|
||||||
|
|||||||
@ -36,4 +36,4 @@ Run `npm run start`
|
|||||||
- all components must be standalone
|
- all components must be standalone
|
||||||
|
|
||||||
# Update latest angular
|
# 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": {
|
"stylePreprocessorOptions": {
|
||||||
"sass": {
|
"sass": {
|
||||||
"silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"]
|
"silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"]
|
||||||
}
|
},
|
||||||
|
"includePaths": ["src"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -81,8 +82,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "1mb",
|
"maximumWarning": "3mb",
|
||||||
"maximumError": "2mb"
|
"maximumError": "4mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"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",
|
"ng": "ng",
|
||||||
"start": "npm run cache-locale && ng serve --host 0.0.0.0",
|
"start": "npm run cache-locale && ng serve --host 0.0.0.0",
|
||||||
"build": "npm run cache-locale && ng build",
|
"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",
|
"minify-langs": "node minify-json.js",
|
||||||
"cache-locale": "node hash-localization.js",
|
"cache-locale": "node hash-localization.js",
|
||||||
"cache-locale-prime": "node hash-localization-prime.js",
|
"cache-locale-prime": "node hash-localization-prime.js",
|
||||||
@ -16,70 +17,72 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-slider/ngx-slider": "^19.0.0",
|
"@angular-slider/ngx-slider": "^20.0.0",
|
||||||
"@angular/animations": "^19.2.5",
|
"@angular/animations": "^20.1.4",
|
||||||
"@angular/cdk": "^19.2.8",
|
"@angular/cdk": "^20.1.4",
|
||||||
"@angular/common": "^19.2.5",
|
"@angular/common": "^20.1.4",
|
||||||
"@angular/compiler": "^19.2.5",
|
"@angular/compiler": "^20.1.4",
|
||||||
"@angular/core": "^19.2.5",
|
"@angular/core": "^20.1.4",
|
||||||
"@angular/forms": "^19.2.5",
|
"@angular/forms": "^20.1.4",
|
||||||
"@angular/localize": "^19.2.5",
|
"@angular/localize": "^20.1.4",
|
||||||
"@angular/platform-browser": "^19.2.5",
|
"@angular/platform-browser": "^20.1.4",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.5",
|
"@angular/platform-browser-dynamic": "^20.1.4",
|
||||||
"@angular/router": "^19.2.5",
|
"@angular/router": "^20.1.4",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@iharbeck/ngx-virtual-scroller": "^19.0.1",
|
"@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": "^7.6.1",
|
||||||
"@jsverse/transloco-locale": "^7.0.1",
|
"@jsverse/transloco-locale": "^7.0.1",
|
||||||
"@jsverse/transloco-persist-lang": "^7.0.2",
|
"@jsverse/transloco-persist-lang": "^7.0.2",
|
||||||
"@jsverse/transloco-persist-translations": "^7.0.1",
|
"@jsverse/transloco-persist-translations": "^7.0.1",
|
||||||
"@jsverse/transloco-preload-langs": "^7.0.1",
|
"@jsverse/transloco-preload-langs": "^7.0.1",
|
||||||
"@microsoft/signalr": "^8.0.7",
|
"@microsoft/signalr": "^9.0.6",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@popperjs/core": "^2.11.7",
|
"@popperjs/core": "^2.11.7",
|
||||||
"@siemens/ngx-datatable": "^22.4.1",
|
"@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",
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
"charts.css": "^1.1.0",
|
"charts.css": "^1.2.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.7.1",
|
||||||
"ng-circle-progress": "^1.7.1",
|
"ng-circle-progress": "^1.7.1",
|
||||||
"ng-lazyload-image": "^9.1.3",
|
"ng-lazyload-image": "^9.1.3",
|
||||||
"ng-select2-component": "^17.2.4",
|
"ng-select2-component": "^17.2.4",
|
||||||
"ngx-color-picker": "^19.0.0",
|
"ngx-extended-pdf-viewer": "^24.1.0",
|
||||||
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
|
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
|
"ngx-quill": "^28.0.1",
|
||||||
"ngx-stars": "^1.6.5",
|
"ngx-stars": "^1.6.5",
|
||||||
"ngx-toastr": "^19.0.0",
|
"ngx-toastr": "^19.0.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"screenfull": "^6.0.2",
|
"screenfull": "^6.0.2",
|
||||||
"swiper": "^8.4.6",
|
"swiper": "^11.2.10",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "^0.15.0"
|
"zone.js": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "^19.3.0",
|
"@angular-eslint/builder": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin": "^19.3.0",
|
"@angular-eslint/eslint-plugin": "^20.1.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "^19.3.0",
|
"@angular-eslint/eslint-plugin-template": "^20.1.1",
|
||||||
"@angular-eslint/schematics": "^19.3.0",
|
"@angular-eslint/schematics": "^20.1.1",
|
||||||
"@angular-eslint/template-parser": "^19.3.0",
|
"@angular-eslint/template-parser": "^20.1.1",
|
||||||
"@angular/build": "^19.2.6",
|
"@angular/build": "^20.1.4",
|
||||||
"@angular/cli": "^19.2.6",
|
"@angular/cli": "^20.1.4",
|
||||||
"@angular/compiler-cli": "^19.2.5",
|
"@angular/compiler-cli": "^20.1.4",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/node": "^22.13.13",
|
"@types/marked": "^5.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
"@types/node": "^24.0.14",
|
||||||
"@typescript-eslint/parser": "^8.28.0",
|
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||||
"eslint": "^9.23.0",
|
"@typescript-eslint/parser": "^8.38.0",
|
||||||
|
"eslint": "^9.31.0",
|
||||||
"jsonminify": "^0.4.2",
|
"jsonminify": "^0.4.2",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.8.3",
|
||||||
"webpack-bundle-analyzer": "^4.10.2"
|
"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;
|
avgHoursToRead: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
wordCount: 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,5 +1,6 @@
|
|||||||
import {PageLayoutMode} from '../page-layout-mode';
|
import {PageLayoutMode} from '../page-layout-mode';
|
||||||
import {SiteTheme} from './site-theme';
|
import {SiteTheme} from './site-theme';
|
||||||
|
import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot";
|
||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ export interface Preferences {
|
|||||||
collapseSeriesRelationships: boolean;
|
collapseSeriesRelationships: boolean;
|
||||||
shareReviews: boolean;
|
shareReviews: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
bookReaderHighlightSlots: HighlightSlot[];
|
||||||
|
|
||||||
// Kavita+
|
// Kavita+
|
||||||
aniListScrobblingEnabled: boolean;
|
aniListScrobblingEnabled: boolean;
|
||||||
|
|||||||
@ -6,5 +6,20 @@ export interface PageBookmark {
|
|||||||
seriesId: number;
|
seriesId: number;
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
series: Series;
|
/**
|
||||||
|
* 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;
|
pageNumber: number;
|
||||||
title: string;
|
title: string;
|
||||||
bookScrollId: string | undefined;
|
bookScrollId: string | undefined;
|
||||||
|
selectedText: string | null;
|
||||||
|
chapterTitle: string | null;
|
||||||
/* Ui Only */
|
/* Ui Only */
|
||||||
position: 0;
|
position: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {Genre} from "../metadata/genre";
|
|||||||
import {ReadingList} from "../reading-list";
|
import {ReadingList} from "../reading-list";
|
||||||
import {UserCollection} from "../collection-tag";
|
import {UserCollection} from "../collection-tag";
|
||||||
import {Person} from "../metadata/person";
|
import {Person} from "../metadata/person";
|
||||||
|
import {Annotation} from "../../book-reader/_models/annotations/annotation";
|
||||||
|
|
||||||
export class SearchResultGroup {
|
export class SearchResultGroup {
|
||||||
libraries: Array<Library> = [];
|
libraries: Array<Library> = [];
|
||||||
@ -20,6 +21,7 @@ export class SearchResultGroup {
|
|||||||
files: Array<MangaFile> = [];
|
files: Array<MangaFile> = [];
|
||||||
chapters: Array<Chapter> = [];
|
chapters: Array<Chapter> = [];
|
||||||
bookmarks: Array<BookmarkSearchResult> = [];
|
bookmarks: Array<BookmarkSearchResult> = [];
|
||||||
|
annotations: Array<Annotation> = [];
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.libraries = [];
|
this.libraries = [];
|
||||||
@ -32,5 +34,6 @@ export class SearchResultGroup {
|
|||||||
this.files = [];
|
this.files = [];
|
||||||
this.chapters = [];
|
this.chapters = [];
|
||||||
this.bookmarks = [];
|
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 {TranslocoService} from "@jsverse/transloco";
|
||||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||||
import {DecimalPipe} from "@angular/common";
|
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'readTimeLeft',
|
name: 'readTimeLeft',
|
||||||
@ -11,10 +10,10 @@ export class ReadTimeLeftPipe implements PipeTransform {
|
|||||||
|
|
||||||
constructor(private readonly translocoService: TranslocoService) {}
|
constructor(private readonly translocoService: TranslocoService) {}
|
||||||
|
|
||||||
transform(readingTimeLeft: HourEstimateRange): string {
|
transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string {
|
||||||
const hoursLabel = readingTimeLeft.avgHours > 1
|
const hoursLabel = readingTimeLeft.avgHours > 1
|
||||||
? this.translocoService.translate('read-time-pipe.hours')
|
? this.translocoService.translate(`read-time-pipe.hours${includeLeftLabel ? '-left' : ''}`)
|
||||||
: this.translocoService.translate('read-time-pipe.hour');
|
: this.translocoService.translate(`read-time-pipe.hour${includeLeftLabel ? '-left' : ''}`);
|
||||||
|
|
||||||
const formattedHours = this.customRound(readingTimeLeft.avgHours);
|
const formattedHours = this.customRound(readingTimeLeft.avgHours);
|
||||||
|
|
||||||
|
|||||||
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 {inject, Injectable} from '@angular/core';
|
||||||
import {DOCUMENT} from '@angular/common';
|
import {DOCUMENT} from '@angular/common';
|
||||||
import {BehaviorSubject, filter, take, tap, timer} from 'rxjs';
|
import {BehaviorSubject, filter, take, tap, timer} from 'rxjs';
|
||||||
import {NavigationEnd, Router} from "@angular/router";
|
import {NavigationEnd, Router} from "@angular/router";
|
||||||
|
import {environment} from "../../environments/environment";
|
||||||
|
|
||||||
interface ColorSpace {
|
interface ColorSpace {
|
||||||
primary: string;
|
primary: string;
|
||||||
@ -30,6 +31,9 @@ const colorScapeSelector = 'colorscape';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ColorscapeService {
|
export class ColorscapeService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
||||||
private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null);
|
private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null);
|
||||||
public readonly colors$ = this.colorSubject.asObservable();
|
public readonly colors$ = this.colorSubject.asObservable();
|
||||||
@ -37,8 +41,12 @@ export class ColorscapeService {
|
|||||||
private minDuration = 1000; // minimum duration
|
private minDuration = 1000; // minimum duration
|
||||||
private maxDuration = 4000; // maximum duration
|
private maxDuration = 4000; // maximum duration
|
||||||
private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace
|
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(
|
this.router.events.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
tap(() => this.checkAndResetColorscapeAfterDelay())
|
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
|
* 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.
|
* 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);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result
|
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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -93,8 +93,8 @@ export class ImageService {
|
|||||||
return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookmarkedImage(chapterId: number, pageNum: number) {
|
getBookmarkedImage(chapterId: number, pageNum: number, imageOffset: number = 0) {
|
||||||
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`;
|
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}&imageOffset=${imageOffset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getWebLinkImage(url: string) {
|
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 {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
|
||||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||||
import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-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 {
|
export enum EVENTS {
|
||||||
UpdateAvailable = 'UpdateAvailable',
|
UpdateAvailable = 'UpdateAvailable',
|
||||||
@ -118,7 +120,11 @@ export enum EVENTS {
|
|||||||
/**
|
/**
|
||||||
* A Rate limit error was hit when matching a series with Kavita+
|
* 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> {
|
export interface Message<T> {
|
||||||
@ -140,11 +146,13 @@ export class MessageHubService {
|
|||||||
/**
|
/**
|
||||||
* Any events that come from the backend
|
* 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
|
* Users that are online
|
||||||
*/
|
*/
|
||||||
public onlineUsers$ = this.onlineUsersSource.asObservable();
|
public onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||||
|
public readonly onlineUsersSignal = toSignal(this.onlineUsers$);
|
||||||
|
|
||||||
constructor() {}
|
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.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
event: EVENTS.NotificationProgress,
|
event: EVENTS.NotificationProgress,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {HttpClient} from '@angular/common/http';
|
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 {DOCUMENT, Location} from '@angular/common';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {environment} from 'src/environments/environment';
|
import {environment} from 'src/environments/environment';
|
||||||
@ -40,6 +40,8 @@ export class ReaderService {
|
|||||||
private readonly location = inject(Location);
|
private readonly location = inject(Location);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly httpClient = inject(HttpClient);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
baseUrl = environment.apiUrl;
|
baseUrl = environment.apiUrl;
|
||||||
encodedKey: string = '';
|
encodedKey: string = '';
|
||||||
@ -50,7 +52,7 @@ export class ReaderService {
|
|||||||
|
|
||||||
private noSleep: NoSleep = new NoSleep();
|
private noSleep: NoSleep = new NoSleep();
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) {
|
constructor() {
|
||||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
this.encodedKey = encodeURIComponent(user.apiKey);
|
this.encodedKey = encodeURIComponent(user.apiKey);
|
||||||
@ -100,12 +102,12 @@ export class ReaderService {
|
|||||||
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
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});
|
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, imageNumber, xpath});
|
||||||
}
|
}
|
||||||
|
|
||||||
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) {
|
||||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
||||||
@ -222,6 +224,10 @@ export class ReaderService {
|
|||||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
|
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
|
* 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
|
* 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) {
|
if (readingListMode) {
|
||||||
this.router.navigateByUrl('lists/' + readingListId);
|
this.router.navigateByUrl('lists/' + readingListId);
|
||||||
} else {
|
return
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
removePersonalToc(chapterId: number, pageNumber: number, title: string) {
|
||||||
@ -326,17 +337,197 @@ export class ReaderService {
|
|||||||
return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId);
|
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) {
|
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});
|
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
getElementFromXPath(path: string) {
|
getElementFromXPath(path: string) {
|
||||||
|
try {
|
||||||
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||||
return node as Element;
|
return node as Element;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.debug("Failed to evaluate XPath:", path, " exception:", e)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -344,27 +535,60 @@ export class ReaderService {
|
|||||||
* @param pureXPath Will ignore shortcuts like id('')
|
* @param pureXPath Will ignore shortcuts like id('')
|
||||||
*/
|
*/
|
||||||
getXPathTo(element: any, pureXPath = false): string {
|
getXPathTo(element: any, pureXPath = false): string {
|
||||||
if (element === null) return '';
|
if (!element) {
|
||||||
if (!pureXPath) {
|
console.error('getXPathTo: element is null or undefined');
|
||||||
if (element.id !== '') { return 'id("' + element.id + '")'; }
|
|
||||||
if (element === document.body) { return element.tagName; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let ix = 0;
|
|
||||||
const siblings = element.parentNode?.childNodes || [];
|
|
||||||
for (let sibling of siblings) {
|
|
||||||
if (sibling === element) {
|
|
||||||
return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
|
|
||||||
}
|
|
||||||
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
|
|
||||||
ix++;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let xpath = this.getXPath(element, pureXPath);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (sibling.tagName === tagName) {
|
||||||
|
siblingIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) {
|
||||||
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
||||||
// Find the continue point chapter and load it
|
// Find the continue point chapter and load it
|
||||||
@ -390,4 +614,5 @@ export class ReaderService {
|
|||||||
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
||||||
{queryParams: {incognitoMode}});
|
{queryParams: {incognitoMode}});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,56 @@
|
|||||||
import { ElementRef, Injectable } from '@angular/core';
|
import {ElementRef, inject, Injectable, signal} from '@angular/core';
|
||||||
import {NavigationEnd, Router} from '@angular/router';
|
import {NavigationEnd, Router} from '@angular/router';
|
||||||
import {filter, ReplaySubject} from 'rxjs';
|
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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ScrollService {
|
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
|
* 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))
|
.pipe(filter(event => event instanceof NavigationEnd))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.scrollContainerSource.next('body');
|
this.scrollContainerSource.next('body');
|
||||||
|
this.cleanup();
|
||||||
});
|
});
|
||||||
this.scrollContainerSource.next('body');
|
this.scrollContainerSource.next('body');
|
||||||
}
|
}
|
||||||
@ -38,18 +70,92 @@ export class ScrollService {
|
|||||||
|| document.body.scrollLeft || 0);
|
|| document.body.scrollLeft || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTo(top: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'smooth') {
|
/**
|
||||||
el.scroll({
|
* Returns true if the log is active
|
||||||
top: top,
|
* @private
|
||||||
behavior: behavior
|
*/
|
||||||
});
|
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') {
|
private intersectionObserver(element: HTMLElement, callback?: () => void) {
|
||||||
el.scroll({
|
const observer = new IntersectionObserver((entries) => {
|
||||||
left: left,
|
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
|
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) {
|
setScrollContainer(elem: ElementRef<HTMLElement> | undefined) {
|
||||||
@ -57,4 +163,155 @@ export class ScrollService {
|
|||||||
this.scrollContainerSource.next(elem);
|
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')">
|
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
|
||||||
<ng-template #switch>
|
<ng-template #switch>
|
||||||
<div class="form-check form-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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-switch>
|
</app-setting-switch>
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
|
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
|
||||||
<form [formGroup]="bulkForm">
|
<form [formGroup]="bulkForm">
|
||||||
<div class="form-check form-switch">
|
<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>
|
<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>
|
<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>
|
<ng-template #includeTypeTooltip>{{t('include-type-tooltip')}}</ng-template>
|
||||||
|
|||||||
@ -1,19 +1,12 @@
|
|||||||
import {
|
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject, OnInit} from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
DestroyRef, effect,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
|
||||||
OnInit
|
|
||||||
} from '@angular/core';
|
|
||||||
import {NavigationStart, Router, RouterOutlet} from '@angular/router';
|
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 {AccountService} from './_services/account.service';
|
||||||
import {LibraryService} from './_services/library.service';
|
import {LibraryService} from './_services/library.service';
|
||||||
import {NavService} from './_services/nav.service';
|
import {NavService} from './_services/nav.service';
|
||||||
import {NgbModal, NgbModalConfig, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbModal, NgbModalConfig, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common';
|
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 {ThemeService} from "./_services/theme.service";
|
||||||
import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.component';
|
import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.component';
|
||||||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.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();
|
const user = this.accountService.currentUserSignal();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
// Bootstrap anything that's needed
|
// Refresh the user data
|
||||||
this.themeService.getThemes().subscribe();
|
this.accountService.refreshAccount().subscribe(account => {
|
||||||
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
|
|
||||||
if (this.accountService.hasAdminRole(user)) {
|
if (this.accountService.hasAdminRole(user)) {
|
||||||
this.licenseService.licenseInfo().subscribe();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, effect, EventEmitter, inject, model} from '@angular/core';
|
||||||
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
import {
|
||||||
|
NgbActiveOffcanvas,
|
||||||
|
NgbNav,
|
||||||
|
NgbNavContent,
|
||||||
|
NgbNavItem,
|
||||||
|
NgbNavLink,
|
||||||
|
NgbNavOutlet
|
||||||
|
} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
import {ReaderService} from "../../../../_services/reader.service";
|
||||||
|
import {PageBookmark} from "../../../../_models/readers/page-bookmark";
|
||||||
|
import {ImageService} from "../../../../_services/image.service";
|
||||||
|
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||||
|
import {ImageComponent} from "../../../../shared/image/image.component";
|
||||||
|
import {
|
||||||
|
PersonalTableOfContentsComponent,
|
||||||
|
PersonalToCEvent
|
||||||
|
} from "../../personal-table-of-contents/personal-table-of-contents.component";
|
||||||
|
|
||||||
|
enum TabID {
|
||||||
|
Image = 1,
|
||||||
|
Text = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadPageEvent {
|
||||||
|
pageNumber: number;
|
||||||
|
part: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-view-bookmarks-drawer',
|
||||||
|
imports: [
|
||||||
|
TranslocoDirective,
|
||||||
|
VirtualScrollerModule,
|
||||||
|
ImageComponent,
|
||||||
|
NgbNav,
|
||||||
|
NgbNavContent,
|
||||||
|
NgbNavLink,
|
||||||
|
PersonalTableOfContentsComponent,
|
||||||
|
NgbNavOutlet,
|
||||||
|
NgbNavItem
|
||||||
|
],
|
||||||
|
templateUrl: './view-bookmark-drawer.component.html',
|
||||||
|
styleUrl: './view-bookmark-drawer.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ViewBookmarkDrawerComponent {
|
||||||
|
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||||
|
private readonly readerService = inject(ReaderService);
|
||||||
|
protected readonly imageService = inject(ImageService);
|
||||||
|
|
||||||
|
|
||||||
|
chapterId = model<number>();
|
||||||
|
bookmarks = model<PageBookmark[]>();
|
||||||
|
/**
|
||||||
|
* Current Page
|
||||||
|
*/
|
||||||
|
pageNum = model.required<number>();
|
||||||
|
loadPage: EventEmitter<PageBookmark | null> = new EventEmitter<PageBookmark | null>();
|
||||||
|
/**
|
||||||
|
* Emitted when a bookmark is removed
|
||||||
|
*/
|
||||||
|
removeBookmark: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
|
||||||
|
/**
|
||||||
|
* Used to refresh the Personal PoC
|
||||||
|
*/
|
||||||
|
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
||||||
|
loadPtoc: EventEmitter<LoadPageEvent | null> = new EventEmitter<LoadPageEvent | null>();
|
||||||
|
|
||||||
|
tocId: TabID = TabID.Image;
|
||||||
|
protected readonly TabID = TabID;
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const id = this.chapterId();
|
||||||
|
if (!id) {
|
||||||
|
console.error('You must pass chapterId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readerService.getBookmarks(id).subscribe(bookmarks => {
|
||||||
|
this.bookmarks.set(bookmarks.sort((a, b) => a.page - b.page));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToBookmark(bookmark: PageBookmark) {
|
||||||
|
const bookmarkCopy = {...bookmark};
|
||||||
|
bookmarkCopy.xPath = this.readerService.scopeBookReaderXpath(bookmarkCopy.xPath ?? '');
|
||||||
|
|
||||||
|
this.loadPage.emit(bookmarkCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBookmark(bookmark: PageBookmark) {
|
||||||
|
this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page, bookmark.imageOffset).subscribe(_ => {
|
||||||
|
const bmarks = this.bookmarks() ?? [];
|
||||||
|
this.bookmarks.set(bmarks.filter(b => b.id !== bookmark.id));
|
||||||
|
// Inform UI to inject/refresh image bookmark icons
|
||||||
|
this.removeBookmark.emit(bookmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From personal table of contents/bookmark
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
loadChapterPart(event: PersonalToCEvent) {
|
||||||
|
const evt = {pageNumber: event.pageNum, part:event.scrollPart} as LoadPageEvent;
|
||||||
|
this.loadPtoc.emit(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.activeOffcanvas.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
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