diff --git a/API/API.csproj b/API/API.csproj index c505640f0..71bd0aa1f 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -81,6 +81,7 @@ + @@ -91,7 +92,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs new file mode 100644 index 000000000..f6fcfbb87 --- /dev/null +++ b/API/Controllers/AnnotationController.cs @@ -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 _logger; + private readonly IBookService _bookService; + private readonly ILocalizationService _localizationService; + private readonly IEventHub _eventHub; + + public AnnotationController(IUnitOfWork unitOfWork, ILogger logger, + IBookService bookService, ILocalizationService localizationService, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _logger = logger; + _bookService = bookService; + _localizationService = localizationService; + _eventHub = eventHub; + } + + /// + /// Returns the annotations for the given chapter + /// + /// + /// + [HttpGet("all")] + public async Task>> GetAnnotations(int chapterId) + { + + return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId)); + } + + /// + /// Returns the Annotation by Id. User must have access to annotation. + /// + /// + /// + [HttpGet("{annotationId}")] + public async Task> GetAnnotation(int annotationId) + { + return Ok(await _unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId)); + } + + /// + /// Create a new Annotation for the user against a Chapter + /// + /// + /// + [HttpPost("create")] + public async Task> 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")); + } + } + + /// + /// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation + /// + /// + /// + [HttpPost("update")] + public async Task> 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(); + } + + /// + /// Delete the annotation for the user + /// + /// + /// + [HttpDelete] + public async Task 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(); + } +} diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs index 7806ef660..bee612fa2 100644 --- a/API/Controllers/BaseApiController.cs +++ b/API/Controllers/BaseApiController.cs @@ -10,4 +10,8 @@ namespace API.Controllers; [Authorize] public class BaseApiController : ControllerBase { + public BaseApiController() + { + + } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index e1d7da9e8..2fcf8567a 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Reader; using API.Entities.Enums; @@ -34,33 +35,41 @@ public class BookController : BaseApiController } /// - /// Retrieves information for the PDF and Epub reader + /// Retrieves information for the PDF and Epub reader. This will cache the file. /// /// This only applies to Epub or PDF files /// /// [HttpGet("{chapterId}/book-info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])] public async Task> GetBookInfo(int chapterId) { var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var bookTitle = string.Empty; + + switch (dto.SeriesFormat) { case MangaFormat.Epub: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); + await _cacheService.Ensure(chapterId); + var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath); + using var book = await EpubReader.OpenBookAsync(file, BookService.LenientBookReaderOptions); bookTitle = book.Title; + break; } case MangaFormat.Pdf: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; + await _cacheService.Ensure(chapterId); + var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath); if (string.IsNullOrEmpty(bookTitle)) { // Override with filename - bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath); + bookTitle = Path.GetFileNameWithoutExtension(file); } break; @@ -72,9 +81,9 @@ public class BookController : BaseApiController break; } - return Ok(new BookInfoDto() + var info = new BookInfoDto() { - ChapterNumber = dto.ChapterNumber, + ChapterNumber = dto.ChapterNumber, VolumeNumber = dto.VolumeNumber, VolumeId = dto.VolumeId, BookTitle = bookTitle, @@ -84,7 +93,10 @@ public class BookController : BaseApiController LibraryId = dto.LibraryId, IsSpecial = dto.IsSpecial, Pages = dto.Pages, - }); + }; + + + return Ok(info); } /// @@ -157,7 +169,11 @@ public class BookController : BaseApiController try { - return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); + var ptocBookmarks = + await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page); + var annotations = await _unitOfWork.UserRepository.GetAnnotationsByPage(User.GetUserId(), chapter.Id, page); + + return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations)); } catch (KavitaException ex) { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 87e0542d1..95ca5bee6 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -193,13 +193,12 @@ public class ImageController : BaseApiController /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey" - ])] - public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey", "imageOffset"])] + public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey, int imageOffset = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); - var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId); + var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, imageOffset, userId); if (bookmark == null) return BadRequest(await _localizationService.Translate(userId, "bookmark-doesnt-exist")); var bookmarkDirectory = diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 0d04c181f..0fd9c437a 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -38,7 +38,7 @@ namespace API.Controllers; #nullable enable /** - * Middleware that checks if Opds has been enabled for this server + * Middleware that checks if Opds has been enabled for this server, and sets OpdsController.UserId in HttpContext */ [AttributeUsage(AttributeTargets.Class)] public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger): ActionFilterAttribute @@ -49,14 +49,13 @@ public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationServ int userId; try { - if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || - apiKeyObj is not string apiKey || context.Controller is not OpdsController controller) + if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey) { context.Result = new BadRequestResult(); return; } - userId = await controller.GetUser(apiKey); + userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == null || userId == 0) { context.Result = new UnauthorizedResult(); @@ -1346,7 +1345,7 @@ public class OpdsController : BaseApiController /// Gets the user from the API key /// /// - public async Task GetUser(string apiKey) + private async Task GetUser(string apiKey) { try { diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 38a5ad482..09790c17b 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json.Nodes; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -15,6 +16,8 @@ using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; using Kavita.Common; @@ -41,6 +44,7 @@ public class ReaderController : BaseApiController private readonly IEventHub _eventHub; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IBookService _bookService; /// public ReaderController(ICacheService cacheService, @@ -48,7 +52,8 @@ public class ReaderController : BaseApiController IReaderService readerService, IBookmarkService bookmarkService, IAccountService accountService, IEventHub eventHub, IScrobblingService scrobblingService, - ILocalizationService localizationService) + ILocalizationService localizationService, + IBookService bookService) { _cacheService = cacheService; _unitOfWork = unitOfWork; @@ -59,6 +64,7 @@ public class ReaderController : BaseApiController _eventHub = eventHub; _scrobblingService = scrobblingService; _localizationService = localizationService; + _bookService = bookService; } /// @@ -218,11 +224,10 @@ public class ReaderController : BaseApiController /// This is generally the first call when attempting to read to allow pre-generation of assets needed for reading /// /// Should Kavita extract pdf into images. Defaults to false. - /// Include file dimensions. Only useful for image based reading + /// Include file dimensions. Only useful for image-based reading /// [HttpGet("chapter-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions" - ])] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])] public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) { if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore @@ -720,26 +725,63 @@ public class ReaderController : BaseApiController [HttpPost("bookmark")] public async Task BookmarkPage(BookmarkDto bookmarkDto) { - // Don't let user save past total pages. - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user == null) return new UnauthorizedResult(); + try + { + // Don't let user save past total pages. + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), + AppUserIncludes.Bookmarks); + if (user == null) return new UnauthorizedResult(); - if (!await _accountService.HasBookmarkPermission(user)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); + if (!await _accountService.HasBookmarkPermission(user)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); - var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); - if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find")); + var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + if (chapter == null || chapter.Files.Count == 0) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find")); - bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page); - var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page); + bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page); - if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) + + string path; + string? chapterTitle; + if (Parser.IsEpub(chapter.Files.First().Extension!)) + { + var cachedFilePath = _cacheService.GetCachedFile(chapter); + path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath); + + + var chapterEntity = await _unitOfWork.ChapterRepository.GetChapterAsync(bookmarkDto.ChapterId); + if (chapterEntity == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + var toc = await _bookService.GenerateTableOfContents(chapterEntity); + chapterTitle = BookService.GetChapterTitleFromToC(toc, bookmarkDto.Page); + } + else + { + path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page); + chapterTitle = chapter.TitleName; + } + + bookmarkDto.ChapterTitle = chapterTitle; + + + + if (string.IsNullOrEmpty(path) || !await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save")); + } + + + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); + return Ok(); + } + catch (KavitaException ex) + { + _logger.LogError(ex, "There was an exception when trying to create a bookmark"); return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save")); - - BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); - return Ok(); + } } + /// /// Removes a bookmarked page for a Chapter /// @@ -826,6 +868,48 @@ public class ReaderController : BaseApiController return _readerService.GetTimeEstimate(0, pagesLeft, false); } + + /// + /// For the current user, returns an estimate on how long it would take to finish reading the chapter. + /// + /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases. + /// + /// + /// + [HttpGet("time-left-for-chapter")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "chapterId"])] + public async Task> 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); + } + + + /// /// Returns the user's personal table of contents for the given chapter /// @@ -879,6 +963,12 @@ public class ReaderController : BaseApiController return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark")); } + // Look up the chapter this PTOC is associated with to get the chapter title (if there is one) + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + var toc = await _bookService.GenerateTableOfContents(chapter); + var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber); + _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() { Title = dto.Title.Trim(), @@ -887,12 +977,16 @@ public class ReaderController : BaseApiController SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, BookScrollId = dto.BookScrollId, + SelectedText = dto.SelectedText, + ChapterTitle = chapterTitle, AppUserId = userId }); await _unitOfWork.CommitAsync(); return Ok(); } + + /// /// Get all progress events for a given chapter /// @@ -905,4 +999,5 @@ public class ReaderController : BaseApiController return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId)); } + } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 2573c8ca8..ae9c7ddd8 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -185,6 +185,11 @@ public class SeriesController : BaseApiController return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter)); } + /// + /// 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")] public async Task> GetChapterMetadata(int chapterId) diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 17ebc758e..abf8468f0 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -109,6 +109,7 @@ public class UsersController : BaseApiController existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; + existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots; if (await _licenseService.HasActiveLicense()) { diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs new file mode 100644 index 000000000..911e3ab47 --- /dev/null +++ b/API/DTOs/Reader/AnnotationDto.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; + +namespace API.DTOs.Reader; + +/// +/// Represents an annotation on a book +/// +public sealed record AnnotationDto +{ + public int Id { get; set; } + /// + /// Starting point of the Highlight + /// + public required string XPath { get; set; } + /// + /// Ending point of the Highlight. Can be the same as + /// + public string EndingXPath { get; set; } + + /// + /// The text selected. + /// + public string SelectedText { get; set; } + /// + /// Rich text Comment + /// + public string? Comment { get; set; } + /// + /// Title of the TOC Chapter within Epub (not Chapter Entity) + /// + public string? ChapterTitle { get; set; } + /// + /// A calculated selection of the surrounding text. This does not update after creation. + /// + public string? Context { get; set; } + + /// + /// The number of characters selected + /// + public int HighlightCount { get; set; } + public bool ContainsSpoiler { get; set; } + public int PageNumber { get; set; } + + /// + /// Selected Highlight Slot Index [0-4] + /// + 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; } +} diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index da18fc28e..b62271408 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -15,7 +15,19 @@ public sealed record BookmarkDto [Required] public int ChapterId { get; set; } /// + /// Only applicable for Epubs + /// + public int ImageOffset { get; set; } + /// + /// Only applicable for Epubs + /// + public string? XPath { get; set; } + /// /// This is only used when getting all bookmarks. /// public SeriesDto? Series { get; set; } + /// + /// Not required, will be filled out at API before saving to the DB + /// + public string? ChapterTitle { get; set; } } diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 95272ca58..545e17e47 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto public required int PageNumber { get; set; } public required string Title { get; set; } public string? BookScrollId { get; set; } + public string? SelectedText { get; set; } } diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index c979d9d78..66994a7ff 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -4,8 +4,27 @@ public sealed record PersonalToCDto { + public required int Id { get; init; } public required int ChapterId { get; set; } + /// + /// The page to bookmark + /// public required int PageNumber { get; set; } + /// + /// The title of the bookmark. Defaults to Page {PageNumber} if not set + /// public required string Title { get; set; } + /// + /// 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 + /// public string? BookScrollId { get; set; } + /// + /// Text of the bookmark + /// + public string? SelectedText { get; set; } + /// + /// Title of the Chapter this PToC was created in + /// + /// Taken from the ToC + public string? ChapterTitle { get; set; } } diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index 11c4bdc08..ba385b553 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -23,6 +23,7 @@ public sealed record SearchResultGroupDto public IEnumerable Files { get; set; } = default!; public IEnumerable Chapters { get; set; } = default!; public IEnumerable Bookmarks { get; set; } = default!; + public IEnumerable Annotations { get; set; } = default!; } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 46f42306e..b7fd625f4 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; @@ -41,4 +42,7 @@ public sealed record UserPreferencesDto public bool AniListScrobblingEnabled { get; set; } /// public bool WantToReadSync { get; set; } + /// + [Required] + public List BookReaderHighlightSlots { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index b558d4989..a455cc32f 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext MetadataFieldMapping { get; set; } = null!; public DbSet AppUserChapterRating { get; set; } = null!; public DbSet AppUserReadingProfiles { get; set; } = null!; + public DbSet AppUserAnnotation { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -301,6 +302,14 @@ public sealed class DataContext : IdentityDbContext()); + builder.Entity() + .Property(a => a.BookReaderHighlightSlots) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); + builder.Entity() .Property(user => user.IdentityProvider) .HasDefaultValue(IdentityProvider.Kavita); diff --git a/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs b/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs new file mode 100644 index 000000000..3a5b14539 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs @@ -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; + +/// +/// v0.8.8 - Switch existing xpaths saved to a descoped version +/// +public static class ManualMigrateBookReadingProgress +{ + /// + /// Scope from 2023 era before a DOM change + /// + 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]/"; + /// + /// Scope from post DOM change + /// + 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]/"; + /// + /// New-descoped prefix + /// + private const string ReplacementScope = "//BODY/DIV[1]"; + + public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger 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"); + } +} diff --git a/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs b/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs new file mode 100644 index 000000000..a8822d149 --- /dev/null +++ b/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs @@ -0,0 +1,3849 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250820150458_BookAnnotations")] + partial class BookAnnotations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250820150458_BookAnnotations.cs b/API/Data/Migrations/20250820150458_BookAnnotations.cs new file mode 100644 index 000000000..ac0e88f8e --- /dev/null +++ b/API/Data/Migrations/20250820150458_BookAnnotations.cs @@ -0,0 +1,137 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class BookAnnotations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChapterTitle", + table: "AppUserTableOfContent", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SelectedText", + table: "AppUserTableOfContent", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "BookReaderHighlightSlots", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "ChapterTitle", + table: "AppUserBookmark", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImageOffset", + table: "AppUserBookmark", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "XPath", + table: "AppUserBookmark", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "AppUserAnnotation", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + XPath = table.Column(type: "TEXT", nullable: true), + EndingXPath = table.Column(type: "TEXT", nullable: true), + SelectedText = table.Column(type: "TEXT", nullable: true), + Comment = table.Column(type: "TEXT", nullable: true), + HighlightCount = table.Column(type: "INTEGER", nullable: false), + PageNumber = table.Column(type: "INTEGER", nullable: false), + SelectedSlotIndex = table.Column(type: "INTEGER", nullable: false), + Context = table.Column(type: "TEXT", nullable: true), + ContainsSpoiler = table.Column(type: "INTEGER", nullable: false), + ChapterTitle = table.Column(type: "TEXT", nullable: true), + LibraryId = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index b662ab9f9..ef16fe3ec 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -162,6 +162,78 @@ namespace API.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.ToTable("AppUserAnnotation"); + }); + modelBuilder.Entity("API.Entities.AppUserBookmark", b => { b.Property("Id") @@ -174,6 +246,9 @@ namespace API.Data.Migrations b.Property("ChapterId") .HasColumnType("INTEGER"); + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + b.Property("Created") .HasColumnType("TEXT"); @@ -183,6 +258,9 @@ namespace API.Data.Migrations b.Property("FileName") .HasColumnType("TEXT"); + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -198,6 +276,9 @@ namespace API.Data.Migrations b.Property("VolumeId") .HasColumnType("INTEGER"); + b.Property("XPath") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("AppUserId"); @@ -434,6 +515,11 @@ namespace API.Data.Migrations b.Property("BookReaderFontSize") .HasColumnType("INTEGER"); + b.Property("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("BookReaderImmersiveMode") .HasColumnType("INTEGER"); @@ -834,6 +920,9 @@ namespace API.Data.Migrations b.Property("ChapterId") .HasColumnType("INTEGER"); + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + b.Property("Created") .HasColumnType("TEXT"); @@ -852,6 +941,9 @@ namespace API.Data.Migrations b.Property("PageNumber") .HasColumnType("INTEGER"); + b.Property("SelectedText") + .HasColumnType("TEXT"); + b.Property("SeriesId") .HasColumnType("INTEGER"); @@ -2834,6 +2926,25 @@ namespace API.Data.Migrations b.ToTable("SeriesMetadataTag"); }); + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + }); + modelBuilder.Entity("API.Entities.AppUserBookmark", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -3620,6 +3731,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.AppUser", b => { + b.Navigation("Annotations"); + b.Navigation("Bookmarks"); b.Navigation("ChapterRatings"); diff --git a/API/Data/Repositories/AnnotationRepository.cs b/API/Data/Repositories/AnnotationRepository.cs new file mode 100644 index 000000000..527caa4c7 --- /dev/null +++ b/API/Data/Repositories/AnnotationRepository.cs @@ -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 GetAnnotationDto(int id); + Task 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 GetAnnotationDto(int id) + { + return await context.AppUserAnnotation + .ProjectTo(mapper.ConfigurationProvider) + .FirstOrDefaultAsync(a => a.Id == id); + } + + public async Task GetAnnotation(int id) + { + return await context.AppUserAnnotation + .FirstOrDefaultAsync(a => a.Id == id); + } +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 27d21df74..81a140f5b 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -127,8 +127,8 @@ public class ChapterRepository : IChapterRepository }) .Select(data => new ChapterInfoDto() { - ChapterNumber = data.ChapterNumber + string.Empty, // TODO: Fix this - VolumeNumber = data.VolumeNumber + string.Empty, // TODO: Fix this + ChapterNumber = data.ChapterNumber + string.Empty, + VolumeNumber = data.VolumeNumber + string.Empty, VolumeId = data.VolumeId, IsSpecial = data.IsSpecial, SeriesId = data.SeriesId, diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index d5d89994c..ccb829505 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -16,6 +16,7 @@ using API.DTOs.Filtering.v2; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; using API.DTOs.Person; +using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; @@ -402,6 +403,14 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + result.Annotations = await _context.AppUserAnnotation + .Where(a => a.AppUserId == userId && + (EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") || EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%"))) + .Take(maxRecords) + .OrderBy(l => l.CreatedUtc) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + var justYear = _yearRegex.Match(searchQuery).Value; var hasYearInQuery = !string.IsNullOrEmpty(justYear); var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 4a3f3f099..9f101a43e 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -75,7 +75,7 @@ public interface IUserRepository Task> GetBookmarkDtosForChapter(int userId, int chapterId); Task> GetAllBookmarkDtos(int userId, FilterV2Dto filter); Task> GetAllBookmarksAsync(); - Task GetBookmarkForPage(int page, int chapterId, int userId); + Task GetBookmarkForPage(int page, int chapterId, int imageOffset, int userId); Task GetBookmarkAsync(int bookmarkId); Task GetUserIdByApiKeyAsync(string apiKey); Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); @@ -107,6 +107,8 @@ public interface IUserRepository Task> GetDashboardStreamsByIds(IList streamIds); Task> GetUserTokenInfo(); Task GetUserByDeviceEmail(string deviceEmail); + Task> GetAnnotations(int userId, int chapterId); + Task> GetAnnotationsByPage(int userId, int chapterId, int pageNum); /// /// Try getting a user by the id provided by OIDC /// @@ -114,6 +116,8 @@ public interface IUserRepository /// /// Task GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None); + + Task GetAnnotationDtoById(int userId, int annotationId); } public class UserRepository : IUserRepository @@ -228,18 +232,18 @@ public class UserRepository : IUserRepository return await _context.AppUserBookmark.ToListAsync(); } - public async Task GetBookmarkForPage(int page, int chapterId, int userId) + public async Task GetBookmarkForPage(int page, int chapterId, int imageOffset, int userId) { return await _context.AppUserBookmark - .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId) - .SingleOrDefaultAsync(); + .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId && b.ImageOffset == imageOffset) + .FirstOrDefaultAsync(); } public async Task GetBookmarkAsync(int bookmarkId) { return await _context.AppUserBookmark .Where(b => b.Id == bookmarkId) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); } @@ -557,13 +561,39 @@ public class UserRepository : IUserRepository /// /// /// - public async Task GetUserByDeviceEmail(string deviceEmail) + public async Task GetUserByDeviceEmail(string deviceEmail) { return await _context.AppUser .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) .FirstOrDefaultAsync(); } + /// + /// Returns a list of annotations ordered by page number. + /// + /// + /// + /// + public async Task> 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(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> 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(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None) { if (string.IsNullOrEmpty(oidcId)) return null; @@ -574,6 +604,14 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(); } + public async Task GetAnnotationDtoById(int userId, int annotationId) + { + return await _context.AppUserAnnotation + .Where(a => a.AppUserId == userId && a.Id == annotationId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + public async Task> GetAdminUsersAsync() { diff --git a/API/Data/Repositories/UserTableOfContentRepository.cs b/API/Data/Repositories/UserTableOfContentRepository.cs index b640ec9a0..34b3994de 100644 --- a/API/Data/Repositories/UserTableOfContentRepository.cs +++ b/API/Data/Repositories/UserTableOfContentRepository.cs @@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository void Remove(AppUserTableOfContent toc); Task IsUnique(int userId, int chapterId, int page, string title); IEnumerable GetPersonalToC(int userId, int chapterId); + Task> GetPersonalToCForPage(int userId, int chapterId, int page); Task Get(int userId, int chapterId, int pageNum, string title); } @@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository .AsEnumerable(); } + public async Task> GetPersonalToCForPage(int userId, int chapterId, int page) + { + return await _context.AppUserTableOfContent + .Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.PageNumber) + .ToListAsync(); + } + public async Task Get(int userId,int chapterId, int pageNum, string title) { return await _context.AppUserTableOfContent diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index e874e810b..20a34bd8a 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -30,6 +30,45 @@ public static class Seed /// public static ImmutableArray DefaultSettings; + public static readonly ImmutableArray 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 DefaultThemes = [ ..new List { @@ -45,8 +84,8 @@ public static class Seed }.ToArray() ]; - public static readonly ImmutableArray DefaultStreams = ImmutableArray.Create( - new List + public static readonly ImmutableArray DefaultStreams = [ + ..new List { new() { @@ -80,38 +119,40 @@ public static class Seed IsProvided = true, Visible = false }, - }.ToArray()); + }.ToArray() + ]; - public static readonly ImmutableArray DefaultSideNavStreams = ImmutableArray.Create( - new AppUserSideNavStream() + public static readonly ImmutableArray DefaultSideNavStreams = + [ + new() { Name = "want-to-read", StreamType = SideNavStreamType.WantToRead, Order = 1, IsProvided = true, Visible = true - }, new AppUserSideNavStream() + }, new() { Name = "collections", StreamType = SideNavStreamType.Collections, Order = 2, IsProvided = true, Visible = true - }, new AppUserSideNavStream() + }, new() { Name = "reading-lists", StreamType = SideNavStreamType.ReadingLists, Order = 3, IsProvided = true, Visible = true - }, new AppUserSideNavStream() + }, new() { Name = "bookmarks", StreamType = SideNavStreamType.Bookmarks, Order = 4, IsProvided = true, Visible = true - }, new AppUserSideNavStream() + }, new() { Name = "all-series", StreamType = SideNavStreamType.AllSeries, @@ -119,14 +160,15 @@ public static class Seed IsProvided = true, Visible = true }, - new AppUserSideNavStream() + new() { Name = "browse-authors", StreamType = SideNavStreamType.BrowsePeople, Order = 6, IsProvided = true, Visible = true - }); + } + ]; public static async Task SeedRoles(RoleManager roleManager) @@ -215,59 +257,74 @@ public static class Seed } } + public static async Task SeedDefaultHighlightSlots(IUnitOfWork unitOfWork) + { + var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences); + foreach (var user in allUsers) + { + if (user.UserPreferences.BookReaderHighlightSlots.Any()) break; + + user.UserPreferences.BookReaderHighlightSlots = DefaultHighlightSlots.ToList(); + unitOfWork.UserRepository.Update(user); + } + await unitOfWork.CommitAsync(); + } + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); - DefaultSettings = ImmutableArray.Create(new List() - { - new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, - new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, - new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, - new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"}, - new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"}, - new() + DefaultSettings = [ + ..new List() { - Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory) - }, - new() - { - Key = ServerSettingKey.Port, Value = Configuration.DefaultHttpPort + string.Empty - }, // Not used from DB, but DB is sync with appSettings.json - new() { - Key = ServerSettingKey.IpAddresses, Value = Configuration.DefaultIpAddresses - }, // Not used from DB, but DB is sync with appSettings.json - new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, - new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, - new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, - new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, - new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, - new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, - new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, - new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, - new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, - new() {Key = ServerSettingKey.HostName, Value = string.Empty}, - new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()}, - new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty}, - new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"}, - new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"}, - new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()}, - new() { - Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty - }, // Not used from DB, but DB is sync with appSettings.json - new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())}, + new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, + new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, + new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, + new() {Key = ServerSettingKey.TaskCleanup, Value = "daily"}, + new() {Key = ServerSettingKey.LoggingLevel, Value = "Debug"}, + new() + { + Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory) + }, + new() + { + Key = ServerSettingKey.Port, Value = Configuration.DefaultHttpPort + string.Empty + }, // Not used from DB, but DB is sync with appSettings.json + new() { + Key = ServerSettingKey.IpAddresses, Value = Configuration.DefaultIpAddresses + }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, + new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, + new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, + new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, + new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, + new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, + new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, + new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, + new() {Key = ServerSettingKey.HostName, Value = string.Empty}, + new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()}, + new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty}, + new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"}, + new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"}, + new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()}, + new() { + Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty + }, // Not used from DB, but DB is sync with appSettings.json + new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())}, - new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty}, - new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"}, - new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, - new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, - new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, - new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, - }.ToArray()); + new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"}, + new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, + new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, + new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, + }.ToArray() + ]; foreach (var defaultSetting in DefaultSettings) { diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index d72dd3bc7..cb8641efe 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -34,6 +34,7 @@ public interface IUnitOfWork IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } IEmailHistoryRepository EmailHistoryRepository { get; } IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } + IAnnotationRepository AnnotationRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -76,6 +77,7 @@ public class UnitOfWork : IUnitOfWork ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper); + AnnotationRepository = new AnnotationRepository(_context, _mapper); } /// @@ -106,6 +108,7 @@ public class UnitOfWork : IUnitOfWork public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } public IEmailHistoryRepository EmailHistoryRepository { get; } public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } + public IAnnotationRepository AnnotationRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 5fc0c94dd..7c93c6b5c 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -48,6 +48,7 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// A list of Table of Contents for a given Chapter /// public ICollection TableOfContents { get; set; } = null!; + public ICollection Annotations { get; set; } = null!; /// /// An API Key to interact with external services, like OPDS /// diff --git a/API/Entities/AppUserAnnotation.cs b/API/Entities/AppUserAnnotation.cs new file mode 100644 index 000000000..434e610fd --- /dev/null +++ b/API/Entities/AppUserAnnotation.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; +using API.Entities.Interfaces; + +namespace API.Entities; + +/// +/// Represents an annotation in the Epub reader +/// +public class AppUserAnnotation : IEntityDate +{ + public int Id { get; set; } + /// + /// Starting point of the Highlight + /// + public required string XPath { get; set; } + /// + /// Ending point of the Highlight. Can be the same as + /// + public string EndingXPath { get; set; } + + /// + /// The text selected. + /// + public string SelectedText { get; set; } + /// + /// Rich text Comment + /// + public string? Comment { get; set; } + /// + /// The number of characters selected + /// + public int HighlightCount { get; set; } + public int PageNumber { get; set; } + /// + /// Selected Highlight Slot Index [0-4] + /// + public int SelectedSlotIndex { get; set; } + /// + /// A calculated selection of the surrounding text. This does not update after creation. + /// + public string? Context { get; set; } + + public bool ContainsSpoiler { get; set; } + + // TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?) + + /// + /// Title of the TOC Chapter within Epub (not Chapter Entity) + /// + 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; } +} diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index d17e8eaf0..ef55bf551 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -4,6 +4,7 @@ using API.Entities.Interfaces; namespace API.Entities; + /// /// Represents a saved page in a Chapter entity for a given user. /// @@ -19,7 +20,19 @@ public class AppUserBookmark : IEntityDate /// Filename in the Bookmark Directory /// public string FileName { get; set; } = string.Empty; - + /// + /// Only applicable for Epubs - handles multiple images on one page + /// + /// 0-based index of the image position on page + public int ImageOffset { get; set; } + /// + /// Only applicable for Epubs + /// + public string? XPath { get; set; } + /// + /// Chapter name (from ToC) or Title (from ComicInfo/PDF) + /// + public string? ChapterTitle { get; set; } // Relationships [JsonIgnore] diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index b0f21bcba..aa6195143 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -108,6 +108,10 @@ public class AppUserPreferences /// /// Defaults to false public bool BookReaderImmersiveMode { get; set; } = false; + /// + /// Book Reader Option: A set of 5 distinct highlight slots with default colors. User can customize. Binds to all Highlight Annotations (. + /// + public List BookReaderHighlightSlots { get; set; } #endregion #region PdfReader diff --git a/API/Entities/AppUserTableOfContent.cs b/API/Entities/AppUserTableOfContent.cs index bc0f604bc..5d110b8b6 100644 --- a/API/Entities/AppUserTableOfContent.cs +++ b/API/Entities/AppUserTableOfContent.cs @@ -18,6 +18,19 @@ public class AppUserTableOfContent : IEntityDate /// The title of the bookmark. Defaults to Page {PageNumber} if not set /// public required string Title { get; set; } + /// + /// 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 + /// + public string? BookScrollId { get; set; } + /// + /// Text of the bookmark + /// + public string? SelectedText { get; set; } + /// + /// Title of the Chapter this PToC was created in + /// + /// Taken from the ToC + public string? ChapterTitle { get; set; } public required int SeriesId { get; set; } public virtual Series Series { get; set; } @@ -27,10 +40,7 @@ public class AppUserTableOfContent : IEntityDate public int VolumeId { get; set; } public int LibraryId { get; set; } - /// - /// 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 - /// - public string? BookScrollId { get; set; } + public DateTime Created { get; set; } public DateTime CreatedUtc { get; set; } diff --git a/API/Entities/Enums/HightlightColor.cs b/API/Entities/Enums/HightlightColor.cs new file mode 100644 index 000000000..dc60b46e8 --- /dev/null +++ b/API/Entities/Enums/HightlightColor.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Enums; + +/// +/// Color of the highlight +/// +/// Color may not match exactly due to theming +public enum HightlightColor +{ + Blue = 1, + Green = 2, +} diff --git a/API/Entities/HighlightSlot.cs b/API/Entities/HighlightSlot.cs new file mode 100644 index 000000000..b788f8fb2 --- /dev/null +++ b/API/Entities/HighlightSlot.cs @@ -0,0 +1,20 @@ +namespace API.Entities; + +public sealed record HighlightSlot +{ + public int Id { get; set; } + /// + /// Hex representation + /// + 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; } +} diff --git a/API/Helpers/AnnotationHelper.cs b/API/Helpers/AnnotationHelper.cs new file mode 100644 index 000000000..ff428028d --- /dev/null +++ b/API/Helpers/AnnotationHelper.cs @@ -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(); + + + /// + /// Given an xpath that is scoped to the epub reader, transform it into a page-level xpath + /// + /// + /// + public static string DescopeXpath(string xpath) + { + return xpath.Replace(UiXPathScope, "//BODY").ToLowerInvariant(); + } + + public static void InjectSingleElementAnnotations(HtmlDocument doc, List 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( + $"{item.Annotation.SelectedText}"); + 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 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 GetElementsInRange(HtmlNode startElement, HtmlNode endElement) + { + var elements = new List(); + 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 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( + $"{highlightText}"); + 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(); +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index e4a9438c3..864dda0db 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -386,6 +386,10 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); + CreateMap() + .ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName)) + .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)); + CreateMap(); } } diff --git a/API/Helpers/BookChapterItemHelper.cs b/API/Helpers/BookChapterItemHelper.cs new file mode 100644 index 000000000..a4021f036 --- /dev/null +++ b/API/Helpers/BookChapterItemHelper.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Reader; + +namespace API.Helpers; +#nullable enable + +public static class BookChapterItemHelper +{ + /// + /// For a given page, finds all toc items that match the page number. + /// Returns flattened list to allow for best decision making. + /// + /// The table of contents collection + /// Page number to search for + /// Flattened list of all TOC items matching the page + public static IList GetTocForPage(ICollection toc, int pageNum) + { + var flattenedToc = FlattenToc(toc); + return flattenedToc.Where(item => item.Page == pageNum).ToList(); + } + + /// + /// Flattens the hierarchical table of contents into a single list. + /// Preserves all items regardless of nesting level. + /// + /// The hierarchical table of contents + /// Flattened list of all TOC items + public static IList FlattenToc(ICollection toc) + { + var result = new List(); + + foreach (var item in toc) + { + result.Add(item); + + if (item.Children?.Any() == true) + { + var childItems = FlattenToc(item.Children); + result.AddRange(childItems); + } + } + + return result; + } + + /// + /// Gets the most specific (deepest nested) TOC item for a given page. + /// Useful when you want the most granular chapter/section title. + /// + /// The table of contents collection + /// Page number to search for + /// The deepest nested TOC item for the page, or null if none found + public static BookChapterItem? GetMostSpecificTocForPage(ICollection toc, int pageNum) + { + var (item, _) = GetTocItemsWithDepth(toc, pageNum, 0) + .OrderByDescending(x => x.depth) + .FirstOrDefault(); + return item; + } + + /// + /// Helper method that tracks depth while flattening, useful for determining hierarchy level. + /// + /// Table of contents collection + /// Page number to filter by + /// Current nesting depth + /// Items with their depth information + private static IEnumerable<(BookChapterItem item, int depth)> GetTocItemsWithDepth( + ICollection 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; + } + } + } +} diff --git a/API/I18N/en.json b/API/I18N/en.json index 09a990d77..bf07c1140 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -116,6 +116,9 @@ "generic-reading-list-create": "There was an issue creating the reading list", "reading-list-doesnt-exist": "Reading list does not exist", + "annotation-failed-create": "Failed to create annotation, try again", + "annotation-delete": "Couldn't delete annotation", + "series-restricted": "User does not have access to this Series", "generic-scrobble-hold": "An error occurred while adding the hold", diff --git a/API/Program.cs b/API/Program.cs index 011a7de2a..401d81ce0 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -128,6 +128,7 @@ public class Program await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); await Seed.SeedMetadataSettings(context); + await Seed.SeedDefaultHighlightSlots(unitOfWork); } catch (Exception ex) { diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 5cd4b9646..95dc7d67c 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; +using System.Xml.XPath; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; @@ -14,6 +15,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; using API.Helpers; +using API.Services.Tasks.Metadata; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -57,11 +59,13 @@ public interface IBookService /// Where the files will be extracted to. If doesn't exist, will be created. void ExtractPdfImages(string fileFilePath, string targetDirectory); Task> GenerateTableOfContents(Chapter chapter); - Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); + Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List ptocBookmarks, List annotations); Task> CreateKeyToPageMappingAsync(EpubBookRef book); + Task?> GetWordCountsPerPage(string bookFilePath); + Task CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath); } -public class BookService : IBookService +public partial class BookService : IBookService { private readonly ILogger _logger; private readonly IDirectoryService _directoryService; @@ -129,38 +133,25 @@ public class BookService : IBookService private static bool HasClickableHrefPart(HtmlNode anchor) { - return anchor.GetAttributeValue("href", string.Empty).Contains("#") + return anchor.GetAttributeValue("href", string.Empty).Contains('#') + || anchor.GetAttributeValue("href", string.Empty).Contains(".xhtml") + || anchor.GetAttributeValue("href", string.Empty).Contains(".html") && anchor.GetAttributeValue("tabindex", string.Empty) != "-1" && anchor.GetAttributeValue("role", string.Empty) != "presentation"; } public static string GetContentType(EpubContentType type) { - string contentType; - switch (type) + var contentType = type switch { - case EpubContentType.IMAGE_GIF: - contentType = "image/gif"; - break; - case EpubContentType.IMAGE_PNG: - contentType = "image/png"; - break; - case EpubContentType.IMAGE_JPEG: - contentType = "image/jpeg"; - break; - case EpubContentType.FONT_OPENTYPE: - contentType = "font/otf"; - break; - case EpubContentType.FONT_TRUETYPE: - contentType = "font/ttf"; - break; - case EpubContentType.IMAGE_SVG: - contentType = "image/svg+xml"; - break; - default: - contentType = "application/octet-stream"; - break; - } + EpubContentType.IMAGE_GIF => "image/gif", + EpubContentType.IMAGE_PNG => "image/png", + EpubContentType.IMAGE_JPEG => "image/jpeg", + EpubContentType.FONT_OPENTYPE => "font/otf", + EpubContentType.FONT_TRUETYPE => "font/ttf", + EpubContentType.IMAGE_SVG => "image/svg+xml", + _ => "application/octet-stream" + }; return contentType; } @@ -173,7 +164,7 @@ public class BookService : IBookService // Some keys get uri encoded when parsed, so replace any of those characters with original var mappingKey = Uri.UnescapeDataString(hrefParts[0]); - if (!mappings.ContainsKey(mappingKey)) + if (!mappings.TryGetValue(mappingKey, out var mappedPage)) { if (HasClickableHrefPart(anchor)) { @@ -188,7 +179,6 @@ public class BookService : IBookService mappings.TryGetValue(pageKey, out currentPage); } - anchor.Attributes.Add("kavita-page", $"{currentPage}"); anchor.Attributes.Add("kavita-part", part); anchor.Attributes.Remove("href"); @@ -203,7 +193,6 @@ public class BookService : IBookService return; } - var mappedPage = mappings[mappingKey]; anchor.Attributes.Add("kavita-page", $"{mappedPage}"); if (hrefParts.Length > 1) { @@ -321,11 +310,49 @@ public class BookService : IBookService } } + /// + /// For each bookmark on this page, inject a specialized icon + /// + /// + /// + private static void InjectPTOCBookmarks(HtmlDocument doc, List 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($"")); + } + } + + + private static void InjectAnnotations(HtmlDocument doc, List annotations) + { + if (annotations.Count == 0) return; + + var singleElementAnnotations = annotations + .Where(a => !string.IsNullOrEmpty(a.XPath) && a.XPath == a.EndingXPath) + .ToList(); + + var multiElementAnnotations = annotations + .Where(a => !string.IsNullOrEmpty(a.XPath) && !string.IsNullOrEmpty(a.EndingXPath) && a.XPath != a.EndingXPath) + .ToList(); + + AnnotationHelper.InjectSingleElementAnnotations(doc, singleElementAnnotations); + AnnotationHelper.InjectMultiElementAnnotations(doc, multiElementAnnotations); + } + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) { - var images = doc.DocumentNode.SelectNodes("//img") - ?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg"); + ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//img")); + ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//image")); + ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//svg")); + } + private static void ScopeHtmlImageCollection(EpubBookRef book, string apiBase, HtmlNodeCollection? images) + { if (images == null) return; var parent = images[0].ParentNode; @@ -362,6 +389,22 @@ public class BookService : IBookService parent.AddClass("kavita-scale-width-container"); image.AddClass("kavita-scale-width"); } + } + + private static void InjectImages(HtmlDocument doc, EpubBookRef book, string apiBase) + { + var images = doc.DocumentNode.SelectNodes("//img") + ?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg"); + + if (images == null) return; + + var parent = images[0].ParentNode; + + foreach (var image in images) + { + // TODO: How do I make images clickable with state? + //image.AddClass("kavita-scale-width"); + } } @@ -651,6 +694,7 @@ public class BookService : IBookService private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook) { + // TODO: Refactor this to use the Async version try { epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); @@ -871,6 +915,154 @@ public class BookService : IBookService return dict; } + public async Task?> GetWordCountsPerPage(string bookFilePath) + { + var ret = new Dictionary(); + 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 = 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 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"); + } + + /// /// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books) /// then null is returned. This expects only an epub file @@ -1014,15 +1206,27 @@ public class BookService : IBookService /// Body element from the epub /// Epub mappings /// Page number we are loading + /// Ptoc Bookmarks to tie against /// - private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, + Dictionary mappings, int page, List ptocBookmarks, List annotations) { await InlineStyles(doc, book, apiBase, body); RewriteAnchors(page, doc, mappings); + // TODO: Pass bookmarks here for state management ScopeImages(doc, book, apiBase); + InjectImages(doc, book, apiBase); + + // Inject PTOC Bookmark Icons + InjectPTOCBookmarks(doc, ptocBookmarks); + + // Inject Annotations + InjectAnnotations(doc, annotations); + + return PrepareFinalHtml(doc, body); } @@ -1097,53 +1301,29 @@ public class BookService : IBookService { foreach (var navigationItem in navItems) { - if (navigationItem.NestedItems.Count == 0) + var tocItem = CreateToCChapter(book, navigationItem, mappings); + if (tocItem != null) { - CreateToCChapter(book, navigationItem, Array.Empty(), chaptersList, mappings); - continue; + chaptersList.Add(tocItem); } - - var nestedChapters = new List(); - - 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() - }); - } - } - - CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings); } } if (chaptersList.Count != 0) return chaptersList; + + // Rest of your fallback logic remains the same... // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist) var tocPage = book.Content.Html.Local.Select(s => s.Key) .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) || k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase)); if (string.IsNullOrEmpty(tocPage)) return chaptersList; - - // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList; var content = await file.ReadContentAsync(); var doc = new HtmlDocument(); doc.LoadHtml(content); - // TODO: We may want to check if there is a toc.ncs file to better handle nested toc - // We could do a fallback first with ol/lis - - - var anchors = doc.DocumentNode.SelectNodes("//a"); if (anchors == null) return chaptersList; @@ -1164,19 +1344,56 @@ public class BookService : IBookService Title = anchor.InnerText, Page = mappings[key], Part = part, - Children = new List() + Children = [] }); } return chaptersList; } - private static int CountParentDirectory(string path) + private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary mappings) { - const string pattern = @"\.\./"; - var matches = Regex.Matches(path, pattern); + // Get the page mapping for the current navigation item + var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath); + int? page = null; + if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping)) + { + page = mapping; + } - return matches.Count; + // Recursively process nested items + var children = new List(); + 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; } /// @@ -1213,7 +1430,8 @@ public class BookService : IBookService /// The API base for Kavita, to rewrite urls to so we load though our endpoint /// Full epub HTML Page, scoped to Kavita's reader /// All exceptions throw this - public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) + public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, + List ptocBookmarks, List annotations) { using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); @@ -1255,7 +1473,7 @@ public class BookService : IBookService body = doc.DocumentNode.SelectSingleNode("/html/body"); } - return await ScopePage(doc, book, apiBase, body, mappings, page); + return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations); } } catch (Exception ex) { @@ -1267,37 +1485,37 @@ public class BookService : IBookService throw new KavitaException("epub-html-missing"); } - private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList nestedChapters, - ICollection chaptersList, IReadOnlyDictionary mappings) - { - if (navigationItem.Link == null) - { - var item = new BookChapterItem - { - Title = navigationItem.Title, - Children = nestedChapters - }; - if (nestedChapters.Count > 0) - { - item.Page = nestedChapters[0].Page; - } - - chaptersList.Add(item); - } - else - { - var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath); - if (mappings.ContainsKey(groupKey)) - { - chaptersList.Add(new BookChapterItem - { - Title = navigationItem.Title, - Page = mappings[groupKey], - Children = nestedChapters - }); - } - } - } + // private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList nestedChapters, + // ICollection chaptersList, IReadOnlyDictionary mappings) + // { + // if (navigationItem.Link == null) + // { + // var item = new BookChapterItem + // { + // Title = navigationItem.Title, + // Children = nestedChapters + // }; + // if (nestedChapters.Count > 0) + // { + // item.Page = nestedChapters[0].Page; + // } + // + // chaptersList.Add(item); + // } + // else + // { + // var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath); + // if (mappings.ContainsKey(groupKey)) + // { + // chaptersList.Add(new BookChapterItem + // { + // Title = navigationItem.Title, + // Page = mappings[groupKey], + // Children = nestedChapters + // }); + // } + // } + // } /// @@ -1341,6 +1559,28 @@ public class BookService : IBookService return string.Empty; } + public static string? GetChapterTitleFromToC(ICollection? tableOfContents, int pageNumber) + { + if (tableOfContents == null) return null; + + foreach (var item in tableOfContents) + { + // Check if current item matches the page number + if (item.Page == pageNumber) + return item.Title; + + // Recursively search children if they exist + if (item.Children?.Count > 0) + { + var childResult = GetChapterTitleFromToC(item.Children, pageNumber); + if (childResult != null) + return childResult; + } + } + + return null; + } + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { @@ -1430,4 +1670,7 @@ public class BookService : IBookService _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); } } + + [GeneratedRegex(@"\.\./")] + private static partial Regex ParentDirectoryRegex(); } diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 4cd77ddd9..3ca4e1ffc 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -9,6 +9,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using Hangfire; +using Kavita.Common; using Microsoft.Extensions.Logging; namespace API.Services; @@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService /// /// Full path to the cached image that is going to be copied /// If the save to DB and copy was successful - public async Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) + public async Task BookmarkPage(AppUser? userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) { - if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false; + if (userWithBookmarks?.Bookmarks == null) + { + throw new KavitaException("Bookmarks cannot be null!"); + } + try { - var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId); + var userBookmark = userWithBookmarks.Bookmarks + .SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId && b.ImageOffset == bookmarkDto.ImageOffset); if (userBookmark != null) { _logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page); @@ -137,6 +143,9 @@ public class BookmarkService : IBookmarkService SeriesId = bookmarkDto.SeriesId, ChapterId = bookmarkDto.ChapterId, FileName = Path.Join(targetFolderStem, fileInfo.Name), + ImageOffset = bookmarkDto.ImageOffset, + XPath = bookmarkDto.XPath, + ChapterTitle = bookmarkDto.ChapterTitle, AppUserId = userWithBookmarks.Id }; @@ -170,7 +179,7 @@ public class BookmarkService : IBookmarkService public async Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto) { var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x => - x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page); + x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page && x.ImageOffset == bookmarkDto.ImageOffset); try { if (bookmarkToDelete != null) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 283d4b1ac..d2d48e43e 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -41,6 +41,7 @@ public interface ICacheService IEnumerable GetCachedFileDimensions(string cachePath); string GetCachedBookmarkPagePath(int seriesId, int page); string GetCachedFile(Chapter chapter); + string GetCachedFile(int chapterId, string firstFilePath); public void ExtractChapterFiles(string extractPath, IReadOnlyList files, bool extractPdfImages = false); Task CacheBookmarkForSeries(int userId, int seriesId); void CleanupBookmarkCache(int seriesId); @@ -155,6 +156,17 @@ public class CacheService : ICacheService return path; } + public string GetCachedFile(int chapterId, string firstFilePath) + { + var extractPath = GetCachePath(chapterId); + var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(firstFilePath)); + if (!(_directoryService.FileSystem.FileInfo.New(path).Exists)) + { + path = firstFilePath; + } + return path; + } + /// /// Caches the files for the given chapter to CacheDirectory @@ -342,9 +354,7 @@ public class CacheService : ICacheService // Calculate what chapter the page belongs to var path = GetCachePath(chapterId); // NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access - var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) - //.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles - .ToArray(); + var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions); return GetPageFromFiles(files, page); } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 35cfa7b04..2ab135dfc 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -423,11 +423,7 @@ public class EmailService : IEmailService smtpClient.Timeout = 20000; var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None; - await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl); - if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password)) - { - await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password); - } + ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; @@ -445,6 +441,12 @@ public class EmailService : IEmailService try { + await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl); + if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password)) + { + await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password); + } + await smtpClient.SendAsync(email); if (user != null) { diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 7db35bb8e..ac7f6575c 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -23,6 +23,8 @@ public interface ILocalizationService public class LocalizationService : ILocalizationService { + private const string LocaleCacheKey = "locales"; + private readonly IDirectoryService _directoryService; private readonly IMemoryCache _cache; private readonly IUnitOfWork _unitOfWork; @@ -33,6 +35,7 @@ public class LocalizationService : ILocalizationService private readonly string _localizationDirectoryUi; private readonly MemoryCacheEntryOptions _cacheOptions; + private readonly MemoryCacheEntryOptions _localsCacheOptions; public LocalizationService(IDirectoryService directoryService, @@ -62,6 +65,10 @@ public class LocalizationService : ILocalizationService _cacheOptions = new MemoryCacheEntryOptions() .SetSize(1) .SetAbsoluteExpiration(TimeSpan.FromMinutes(15)); + + _localsCacheOptions = new MemoryCacheEntryOptions() + .SetSize(1) + .SetAbsoluteExpiration(TimeSpan.FromHours(24)); } /// @@ -139,6 +146,11 @@ public class LocalizationService : ILocalizationService /// public IEnumerable GetLocales() { + if (_cache.TryGetValue(LocaleCacheKey, out List? cachedLocales) && cachedLocales != null) + { + return cachedLocales; + } + var uiLanguages = _directoryService .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json"); var backendLanguages = _directoryService @@ -246,7 +258,10 @@ public class LocalizationService : ILocalizationService } } - return locales.Values; + var kavitaLocales = locales.Values.ToList(); + _cache.Set(LocaleCacheKey, kavitaLocales, _localsCacheOptions); + + return kavitaLocales; } // Helper methods that would need to be implemented diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 3b3cb37d5..3343c090b 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -356,7 +356,7 @@ public class ReaderService : IReaderService return page; } - private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter) + private static int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter) { if (volume.IsSpecial()) { diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index bff7001bd..4eaf3d278 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -35,7 +35,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService private readonly IReaderService _readerService; private readonly IMediaErrorService _mediaErrorService; - private const int AverageCharactersPerWord = 5; + public const int AverageCharactersPerWord = 5; public WordCountAnalyzerService(ILogger logger, IUnitOfWork unitOfWork, IEventHub eventHub, ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService) @@ -247,7 +247,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService _unitOfWork.MangaFileRepository.Update(file); } - private async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath) { try @@ -256,7 +255,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService doc.LoadHtml(await bookFile.ReadContentAsync()); var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); - return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; + var characterCount = textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0; + return GetWordCount(characterCount); } catch (EpubContentException ex) { @@ -267,4 +267,10 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } } + public static int GetWordCount(int characterCount) + { + if (characterCount == 0) return 0; + return characterCount / AverageCharactersPerWord; + } + } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 87a464e6a..65d9dac57 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,4 +1,5 @@ using System; +using API.DTOs.Reader; using API.DTOs.Update; using API.Entities.Person; using API.Extensions; @@ -156,6 +157,12 @@ public static class MessageFactory /// A Rate limit error was hit when matching a series with Kavita+ /// public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError"; + /// + /// Annotation is updated within the reader + /// + public const string AnnotationUpdate = "AnnotationUpdate"; + + public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -683,6 +690,7 @@ public static class MessageFactory }, }; } + public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName) { return new SignalRMessage() @@ -690,8 +698,20 @@ public static class MessageFactory Name = ExternalMatchRateLimitError, Body = new { - seriesId = seriesId, - seriesName = seriesName, + seriesId, + seriesName, + }, + }; + } + + public static SignalRMessage AnnotationUpdateEvent(AnnotationDto dto) + { + return new SignalRMessage() + { + Name = AnnotationUpdate, + Body = new + { + Annotation = dto }, }; } diff --git a/API/Startup.cs b/API/Startup.cs index eeb9144bb..d803b990e 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -298,6 +298,7 @@ public class Startup // v0.8.8 await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger); + await ManualMigrateBookReadingProgress.Migrate(dataContext, unitOfWork, logger); #endregion @@ -421,6 +422,11 @@ public class Startup opts.IncludeQueryInRequestPath = true; }); + if (Configuration.AllowIFraming) + { + logger.LogCritical("appsetting.json has allow iframing on! This may allow for clickjacking on the server. User beware"); + } + app.Use(async (context, next) => { context.Response.Headers[HeaderNames.Vary] = @@ -433,11 +439,7 @@ public class Startup context.Response.Headers.XFrameOptions = "SAMEORIGIN"; // Setup CSP to ensure we load assets only from these origins - context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';"); - } - else - { - logger.LogCritical("appsetting.json has allow iframing on! This may allow for clickjacking on the server. User beware"); + context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none';"; } await next(); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index ad2d89fa5..0c28ea51a 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,8 +1,15 @@ { "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, - "IpAddresses": "", + "IpAddresses": "", "BaseUrl": "/", "Cache": 75, - "AllowIFraming": false + "AllowIFraming": false, + "OpenIdConnectSettings": { + "Authority": "", + "ClientId": "kavita", + "Secret": "", + "CustomScopes": [], + "Enabled": false + } } \ No newline at end of file diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index d18352aac..937549bb4 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -20,7 +20,7 @@ public static class Configuration private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020 + ? "http://localhost:5020" : "https://plus.kavitareader.com"; public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port diff --git a/UI/Web/README.md b/UI/Web/README.md index 4efc47cbc..e3a3d3592 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -36,4 +36,4 @@ Run `npm run start` - all components must be standalone # Update latest angular -`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular-devkit/build-angular @angular/cdk` +`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular/cdk @angular/animations @angular/common @angular/forms @angular/platform-browser @angular/platform-browser-dynamic @angular/router` diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 07526c667..6c24c56b0 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -62,7 +62,8 @@ "stylePreprocessorOptions": { "sass": { "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] - } + }, + "includePaths": ["src"] } }, "configurations": { @@ -81,8 +82,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1mb", - "maximumError": "2mb" + "maximumWarning": "3mb", + "maximumError": "4mb" }, { "type": "anyComponentStyle", @@ -125,5 +126,31 @@ } } } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index b9cb05b86..9c5234858 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -8,70 +8,72 @@ "name": "kavita-webui", "version": "0.7.12.1", "dependencies": { - "@angular-slider/ngx-slider": "^19.0.0", - "@angular/animations": "^19.2.5", - "@angular/cdk": "^19.2.8", - "@angular/common": "^19.2.5", - "@angular/compiler": "^19.2.5", - "@angular/core": "^19.2.5", - "@angular/forms": "^19.2.5", - "@angular/localize": "^19.2.5", - "@angular/platform-browser": "^19.2.5", - "@angular/platform-browser-dynamic": "^19.2.5", - "@angular/router": "^19.2.5", - "@fortawesome/fontawesome-free": "^6.7.2", + "@angular-slider/ngx-slider": "^20.0.0", + "@angular/animations": "^20.1.4", + "@angular/cdk": "^20.1.4", + "@angular/common": "^20.1.4", + "@angular/compiler": "^20.1.4", + "@angular/core": "^20.1.4", + "@angular/forms": "^20.1.4", + "@angular/localize": "^20.1.4", + "@angular/platform-browser": "^20.1.4", + "@angular/platform-browser-dynamic": "^20.1.4", + "@angular/router": "^20.1.4", + "@fortawesome/fontawesome-free": "^7.0.0", "@iharbeck/ngx-virtual-scroller": "^19.0.1", - "@iplab/ngx-file-upload": "^19.0.3", + "@iplab/ngx-color-picker": "^20.0.0", + "@iplab/ngx-file-upload": "^20.0.0", "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", - "@microsoft/signalr": "^8.0.7", - "@ng-bootstrap/ng-bootstrap": "^18.0.0", + "@microsoft/signalr": "^9.0.6", + "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.7", "@siemens/ngx-datatable": "^22.4.1", - "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@swimlane/ngx-charts": "^23.0.0-alpha.0", "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", - "charts.css": "^1.1.0", + "charts.css": "^1.2.0", "file-saver": "^2.0.5", - "luxon": "^3.6.1", + "luxon": "^3.7.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", "ng-select2-component": "^17.2.4", - "ngx-color-picker": "^19.0.0", - "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", + "ngx-extended-pdf-viewer": "^24.1.0", "ngx-file-drop": "^16.0.0", + "ngx-quill": "^28.0.1", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", "rxjs": "^7.8.2", "screenfull": "^6.0.2", - "swiper": "^8.4.6", + "swiper": "^11.2.10", "tslib": "^2.8.1", - "zone.js": "^0.15.0" + "zone.js": "^0.15.1" }, "devDependencies": { - "@angular-eslint/builder": "^19.3.0", - "@angular-eslint/eslint-plugin": "^19.3.0", - "@angular-eslint/eslint-plugin-template": "^19.3.0", - "@angular-eslint/schematics": "^19.3.0", - "@angular-eslint/template-parser": "^19.3.0", - "@angular/build": "^19.2.6", - "@angular/cli": "^19.2.6", - "@angular/compiler-cli": "^19.2.5", + "@angular-eslint/builder": "^20.1.1", + "@angular-eslint/eslint-plugin": "^20.1.1", + "@angular-eslint/eslint-plugin-template": "^20.1.1", + "@angular-eslint/schematics": "^20.1.1", + "@angular-eslint/template-parser": "^20.1.1", + "@angular/build": "^20.1.4", + "@angular/cli": "^20.1.4", + "@angular/compiler-cli": "^20.1.4", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.6.2", - "@types/node": "^22.13.13", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", - "eslint": "^9.23.0", + "@types/marked": "^5.0.2", + "@types/node": "^24.0.14", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^9.31.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.5.4", + "typescript": "^5.8.3", "webpack-bundle-analyzer": "^4.10.2" } }, @@ -84,6 +86,199 @@ "node": ">=0.10.0" } }, + "node_modules/@algolia/client-abtesting": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.32.0.tgz", + "integrity": "sha512-HG/6Eib6DnJYm/B2ijWFXr4txca/YOuA4K7AsEU0JBrOZSB+RU7oeDyNBPi3c0v0UDDqlkBqM3vBU/auwZlglA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.32.0.tgz", + "integrity": "sha512-8Y9MLU72WFQOW3HArYv16+Wvm6eGmsqbxxM1qxtm0hvSASJbxCm+zQAZe5stqysTlcWo4BJ82KEH1PfgHbJAmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.32.0.tgz", + "integrity": "sha512-w8L+rgyXMCPBKmEdOT+RfgMrF0mT6HK60vPYWLz8DBs/P7yFdGo7urn99XCJvVLMSKXrIbZ2FMZ/i50nZTXnuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.32.0.tgz", + "integrity": "sha512-AdWfynhUeX7jz/LTiFU3wwzJembTbdLkQIOLs4n7PyBuxZ3jz4azV1CWbIP8AjUOFmul6uXbmYza+KqyS5CzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.32.0.tgz", + "integrity": "sha512-bTupJY4xzGZYI4cEQcPlSjjIEzMvv80h7zXGrXY1Y0KC/n/SLiMv84v7Uy+B6AG1Kiy9FQm2ADChBLo1uEhGtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.32.0.tgz", + "integrity": "sha512-if+YTJw1G3nDKL2omSBjQltCHUQzbaHADkcPQrGFnIGhVyHU3Dzq4g46uEv8mrL5sxL8FjiS9LvekeUlL2NRqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.32.0.tgz", + "integrity": "sha512-kmK5nVkKb4DSUgwbveMKe4X3xHdMsPsOVJeEzBvFJ+oS7CkBPmpfHAEq+CcmiPJs20YMv6yVtUT9yPWL5WgAhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.32.0.tgz", + "integrity": "sha512-PZTqjJbx+fmPuT2ud1n4vYDSF1yrT//vOGI9HNYKNA0PM0xGUBWigf5gRivHsXa3oBnUlTyHV9j7Kqx5BHbVHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.32.0.tgz", + "integrity": "sha512-kYYoOGjvNQAmHDS1v5sBj+0uEL9RzYqH/TAdq8wmcV+/22weKt/fjh+6LfiqkS1SCZFYYrwGnirrUhUM36lBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.32.0.tgz", + "integrity": "sha512-jyIBLdskjPAL7T1g57UMfUNx+PzvYbxKslwRUKBrBA6sNEsYCFdxJAtZSLUMmw6MC98RDt4ksmEl5zVMT5bsuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.32.0.tgz", + "integrity": "sha512-eDp14z92Gt6JlFgiexImcWWH+Lk07s/FtxcoDaGrE4UVBgpwqOO6AfQM6dXh1pvHxlDFbMJihHc/vj3gBhPjqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.32.0.tgz", + "integrity": "sha512-rnWVglh/K75hnaLbwSc2t7gCkbq1ldbPgeIKDUiEJxZ4mlguFgcltWjzpDQ/t1LQgxk9HdIFcQfM17Hid3aQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.32.0.tgz", + "integrity": "sha512-LbzQ04+VLkzXY4LuOzgyjqEv/46Gwrk55PldaglMJ4i4eDXSRXGKkwJpXFwsoU+c1HMQlHIyjJBhrfsfdyRmyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -97,44 +292,37 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1902.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.6.tgz", - "integrity": "sha512-Dx6yPxpaE5AhP6UtrVRDCc9Ihq9B65LAbmIh3dNOyeehratuaQS0TYNKjbpaevevJojW840DTg80N+CrlfYp9g==", + "version": "0.2001.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2001.4.tgz", + "integrity": "sha512-lZ9wYv1YDcw2Ggi2/TXXhYs7JAukAJHdZGZn6Co5s1QE774bVled1qK8pf46rSsG1BGn1a9VFsRFOlB/sx6WjA==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.6", - "rxjs": "7.8.1" + "@angular-devkit/core": "20.1.4", + "rxjs": "7.8.2" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/architect/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/@angular-devkit/core": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", - "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.1.4.tgz", + "integrity": "sha512-I5CllQoDrVL20/+0JZk/gmR14n/+mwYIoD1RfBDwnaiHlO9o2whRsJj+LeUd9IA5Hf9MPPx+EkOVQt3vsYU0sQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.2", - "rxjs": "7.8.1", + "rxjs": "7.8.2", "source-map": "0.7.4" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, @@ -147,70 +335,246 @@ } } }, - "node_modules/@angular-devkit/core/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.6.tgz", - "integrity": "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.1.4.tgz", + "integrity": "sha512-dyvlQcXf5XKPRC1qTqzIGkltFHh8mYujPk6qt6Ah2nKp7UeA80ZSAocwOmlBg8t7GjN8ICe4Kese5scT1ByFXQ==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.6", + "@angular-devkit/core": "20.1.4", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" + "ora": "8.2.0", + "rxjs": "7.8.2" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "node_modules/@angular-devkit/schematics/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.1.0" + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/schematics/node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/@angular-eslint/builder": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.3.0.tgz", - "integrity": "sha512-j9xNrzZJq29ONSG6EaeQHve0Squkm6u6Dm8fZgWP7crTFOrtLXn7Wxgxuyl9eddpbWY1Ov1gjFuwBVnxIdyAqg==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-20.1.1.tgz", + "integrity": "sha512-pfCYfocX79CZ5nokZF4gVScUGyLWRKQHZsUkQ5V/1hsaGsahvzDRjxsYz0J9rO0ligSa2pwgUCXEwSY8hhHQBw==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", - "@angular-devkit/core": ">= 19.0.0 < 20.0.0" + "@angular-devkit/architect": ">= 0.2000.0 < 0.2100.0", + "@angular-devkit/core": ">= 20.0.0 < 21.0.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": "*" } }, - "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.3.0.tgz", - "integrity": "sha512-63Zci4pvnUR1iSkikFlNbShF1tO5HOarYd8fvNfmOZwFfZ/1T3j3bCy9YbE+aM5SYrWqPaPP/OcwZ3wJ8WNvqA==", - "dev": true - }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.3.0.tgz", - "integrity": "sha512-nBLslLI20KnVbqlfNW7GcnI9R6cYCvRGjOE2QYhzxM316ciAQ62tvQuXP9ZVnRBLSKDAVnMeC0eTq9O4ysrxrQ==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-20.1.1.tgz", + "integrity": "sha512-h+D6T35UGIuG0keYPH7dc6OTdfTVJ8GoIhCIpoAmVGhdIdfXIISvDvvX/QPiZtTcefik3vEZEGRiI/Nzc5xImw==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.3.0", - "@angular-eslint/utils": "19.3.0" + "@angular-eslint/bundled-angular-compiler": "20.1.1", + "@angular-eslint/utils": "20.1.1", + "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -219,54 +583,103 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.3.0.tgz", - "integrity": "sha512-WyouppTpOYut+wvv13wlqqZ8EHoDrCZxNfGKuEUYK1BPmQlTB8EIZfQH4iR1rFVS28Rw+XRIiXo1x3oC0SOfnA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-20.1.1.tgz", + "integrity": "sha512-dRqfxYvgOC4DZqvRTmxoIUMeIqTzcIkRcMVEuP8qvR10KHAWDkV7xT4f7BAee9deI/lzoAk3tk5wkQg6POQo7Q==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.3.0", - "@angular-eslint/utils": "19.3.0", + "@angular-eslint/bundled-angular-compiler": "20.1.1", + "@angular-eslint/utils": "20.1.1", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { + "@angular-eslint/template-parser": "20.1.1", "@typescript-eslint/types": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": "*" } }, - "node_modules/@angular-eslint/schematics": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.3.0.tgz", - "integrity": "sha512-Wl5sFQ4t84LUb8mJ2iVfhYFhtF55IugXu7rRhPHtgIu9Ty5s1v3HGUx4LKv51m2kWhPPeFOTmjeBv1APzFlmnQ==", + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.1.1.tgz", + "integrity": "sha512-hEWh/upyTj2bhyRmbNnGtlOXhBSEHwLg8/9YYhwmiNApQwKcvcg7lkstZMEVrKievNHZT6Wh4dWZvjRjMqLNSg==", "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin-template/node_modules/@angular-eslint/utils": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-20.1.1.tgz", + "integrity": "sha512-hqbzGqa/0Ua90r4TMn4oZVnLuwIF6dqEfH7SlstB224h/7+nKoi67aHkmUq7VItWXpDDe+f1opeR01GKS9fNog==", + "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": ">= 19.0.0 < 20.0.0", - "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", - "@angular-eslint/eslint-plugin": "19.3.0", - "@angular-eslint/eslint-plugin-template": "19.3.0", - "ignore": "7.0.3", - "semver": "7.7.1", + "@angular-eslint/bundled-angular-compiler": "20.1.1" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.1.1.tgz", + "integrity": "sha512-hEWh/upyTj2bhyRmbNnGtlOXhBSEHwLg8/9YYhwmiNApQwKcvcg7lkstZMEVrKievNHZT6Wh4dWZvjRjMqLNSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin/node_modules/@angular-eslint/utils": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-20.1.1.tgz", + "integrity": "sha512-hqbzGqa/0Ua90r4TMn4oZVnLuwIF6dqEfH7SlstB224h/7+nKoi67aHkmUq7VItWXpDDe+f1opeR01GKS9fNog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "20.1.1" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-20.1.1.tgz", + "integrity": "sha512-4sXU0Gr/RhdW3xSBFRzjhTO9mk6ugXUhUIPc1FRta1pmNnbmkvx22ewnKZE8IeRl8PMyk6xJuxZHq19CW1oWOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 20.0.0 < 21.0.0", + "@angular-devkit/schematics": ">= 20.0.0 < 21.0.0", + "@angular-eslint/eslint-plugin": "20.1.1", + "@angular-eslint/eslint-plugin-template": "20.1.1", + "ignore": "7.0.5", + "semver": "7.7.2", "strip-json-comments": "3.1.1" } }, "node_modules/@angular-eslint/schematics/node_modules/ignore": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", - "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@angular-eslint/template-parser": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.3.0.tgz", - "integrity": "sha512-VxMNgsHXMWbbmZeBuBX5i8pzsSSEaoACVpaE+j8Muk60Am4Mxc0PytJm4n3znBSvI3B7Kq2+vStSRYPkOER4lA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-20.1.1.tgz", + "integrity": "sha512-giIMYORf8P8MbBxh6EUfiR/7Y+omxJtK2C7a8lYTtLSOIGO0D8c8hXx9hTlPcdupVX+xZXDuZ85c9JDen+JSSA==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/bundled-angular-compiler": "20.1.1", "eslint-scope": "^8.0.2" }, "peerDependencies": { @@ -274,109 +687,115 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/utils": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.3.0.tgz", - "integrity": "sha512-ovvbQh96FIJfepHqLCMdKFkPXr3EbcvYc9kMj9hZyIxs/9/VxwPH7x25mMs4VsL6rXVgH2FgG5kR38UZlcTNNw==", + "node_modules/@angular-eslint/template-parser/node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.1.1.tgz", + "integrity": "sha512-hEWh/upyTj2bhyRmbNnGtlOXhBSEHwLg8/9YYhwmiNApQwKcvcg7lkstZMEVrKievNHZT6Wh4dWZvjRjMqLNSg==", "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.3.0" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" - } + "license": "MIT" }, "node_modules/@angular-slider/ngx-slider": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-19.0.0.tgz", - "integrity": "sha512-VVJ+Fij5SKnbltxh6TdoBAUAKWfCnSLRPZ7e+r2uO88t8qte5/KHqVOdK4DWCjBr3rEr4YrPR4ylqBCuAWPsKQ==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-20.0.0.tgz", + "integrity": "sha512-tDBAh3PvgtuQ7ExXlvOGcRcoHFaPybFwIp2mvOI2xO0q0nsUdB4JTOPrGIYNEzOMTZ+wxWNbZ8ZkQx7VwGsNzA==", + "license": "MIT", "dependencies": { "detect-passive-events": "^2.0.3", "rxjs": "^7.8.1", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/forms": "^19.0.0" + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/forms": "^20.0.0" } }, "node_modules/@angular/animations": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.5.tgz", - "integrity": "sha512-m4RtY3z1JuHFCh6OrOHxo25oKEigBDdR/XmdCfXIwfTiObZzNA7VQhysgdrb9IISO99kXbjZUYKDtLzgWT8Klg==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.1.4.tgz", + "integrity": "sha512-y4mq2r6jhAj5QuA3UnWkVfok0EcA22uH+XVb4HBKY7q23/xaQYu2CGdVOVpdUsaPTf3zRD1DkAnTkV3J3ZHIiA==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "19.2.5", - "@angular/core": "19.2.5" + "@angular/common": "20.1.4", + "@angular/core": "20.1.4" } }, "node_modules/@angular/build": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.6.tgz", - "integrity": "sha512-+VBLb4ZPLswwJmgfsTFzGex+Sq/WveNc+uaIWyHYjwnuI17NXe1qAAg1rlp72CqGn0cirisfOyAUwPc/xZAgTg==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.1.4.tgz", + "integrity": "sha512-DClI15kl0t1YijptthQfw0cRSj8Opf8ACsZa1xT3o77BALpeusxS2QzSy6xGH+QnwesTyJFux1oRYjtAKmE2YA==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.6", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", + "@angular-devkit/architect": "0.2001.4", + "@babel/core": "7.27.7", + "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", - "@babel/plugin-syntax-import-attributes": "7.26.0", - "@inquirer/confirm": "5.1.6", - "@vitejs/plugin-basic-ssl": "1.2.0", - "beasties": "0.2.0", + "@inquirer/confirm": "5.1.13", + "@vitejs/plugin-basic-ssl": "2.1.0", + "beasties": "0.3.4", "browserslist": "^4.23.0", - "esbuild": "0.25.1", - "fast-glob": "3.3.3", + "esbuild": "0.25.5", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", - "listr2": "8.2.5", + "jsonc-parser": "3.3.1", + "listr2": "8.3.3", "magic-string": "0.30.17", "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "7.0.0", + "parse5-html-rewriting-stream": "7.1.0", "picomatch": "4.0.2", - "piscina": "4.8.0", - "rollup": "4.34.8", - "sass": "1.85.0", - "semver": "7.7.1", + "piscina": "5.1.2", + "rollup": "4.44.1", + "sass": "1.89.2", + "semver": "7.7.2", "source-map-support": "0.5.21", - "vite": "6.2.4", - "watchpack": "2.4.2" + "tinyglobby": "0.2.14", + "vite": "7.0.6", + "watchpack": "2.4.4" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "optionalDependencies": { - "lmdb": "3.2.6" + "lmdb": "3.4.1" }, "peerDependencies": { - "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", - "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", - "@angular/localize": "^19.0.0 || ^19.2.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", - "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.6", + "@angular/compiler": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/localize": "^20.0.0", + "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", + "@angular/service-worker": "^20.0.0", + "@angular/ssr": "^20.1.4", "karma": "^6.4.0", "less": "^4.2.0", - "ng-packagr": "^19.0.0 || ^19.2.0-next.0", + "ng-packagr": "^20.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "typescript": ">=5.5 <5.9" + "tslib": "^2.3.0", + "typescript": ">=5.8 <5.9", + "vitest": "^3.1.1" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "@angular/localize": { "optional": true }, + "@angular/platform-browser": { + "optional": true + }, "@angular/platform-server": { "optional": true }, @@ -400,25 +819,29 @@ }, "tailwindcss": { "optional": true + }, + "vitest": { + "optional": true } } }, "node_modules/@angular/build/node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz", + "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.27.7", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -465,172 +888,440 @@ } }, "node_modules/@angular/cdk": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.8.tgz", - "integrity": "sha512-ZZqWVYFF80TdjWkk2sc9Pn2luhiYeC78VH3Yjeln4wXMsTGDsvKPBcuOxSxxpJ31saaVBehDjBUuXMqGRj8KuA==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.1.4.tgz", + "integrity": "sha512-Uz0fLZRWpKG7xniXSw3Hr4QEvTlVurov07BBz6nRWseGxeHCDkFqKc3UEriovCQ7ylJdR6miIu7j+h4PWLH48g==", + "license": "MIT", "dependencies": { - "parse5": "^7.1.2", + "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^19.0.0 || ^20.0.0", - "@angular/core": "^19.0.0 || ^20.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@angular/cli": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.6.tgz", - "integrity": "sha512-eZhFOSsDUHKaciwcWdU5C54ViAvPPdZJf42So93G2vZWDtEq6Uk47huocn1FY9cMhDvURfYLNrrLMpUDtUSsSA==", - "dev": true, + "node_modules/@angular/cdk/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@angular/cdk/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.6", - "@angular-devkit/core": "19.2.6", - "@angular-devkit/schematics": "19.2.6", - "@inquirer/prompts": "7.3.2", - "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.6", + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@angular/cli": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.1.4.tgz", + "integrity": "sha512-VAQ/EBelBPiX1vV57TZJRPcao/e+Ee9IeLK43fsE2xL+GuEjrJ/fQXqt7OesrgIJHJBwUiX+j8pMMT6VfT1xSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2001.4", + "@angular-devkit/core": "20.1.4", + "@angular-devkit/schematics": "20.1.4", + "@inquirer/prompts": "7.6.0", + "@listr2/prompt-adapter-inquirer": "2.0.22", + "@modelcontextprotocol/sdk": "1.13.3", + "@schematics/angular": "20.1.4", "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.32.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", - "listr2": "8.2.5", + "listr2": "8.3.3", "npm-package-arg": "12.0.2", "npm-pick-manifest": "10.0.0", - "pacote": "20.0.0", + "pacote": "21.0.0", "resolve": "1.22.10", - "semver": "7.7.1", - "symbol-observable": "4.0.0", - "yargs": "17.7.2" + "semver": "7.7.2", + "yargs": "18.0.0", + "zod": "3.25.75" }, "bin": { "ng": "bin/ng.js" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@angular/cli/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/cli/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular/cli/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@angular/cli/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@angular/cli/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@angular/common": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.5.tgz", - "integrity": "sha512-vFCBdas4C5PxP6ts/4TlRddWD3DUmI3aaO0QZdZvqyLHy428t84ruYdsJXKaeD8ie2U4/9F3a1tsklclRG/BBA==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.1.4.tgz", + "integrity": "sha512-AL+HdsY5xL2iM1zZ55ce33U+w2LgPJZQwKvHXJJ/Hpk3rpFNamWtRPmJBeq8Z0dQV1lLTMM+2pUatH6p+5pvEg==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "19.2.5", + "@angular/core": "20.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.5.tgz", - "integrity": "sha512-34J+HubQjwkbZ0AUtU5sa4Zouws9XtP/fKaysMQecoYJTZ3jewzLSRu3aAEZX1Y4gIrcVVKKIxM6oWoXKwYMOA==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.1.4.tgz", + "integrity": "sha512-gQbchh2ziK9QxZuHgEf7BUMCm/ayu6Zr9hst6itSecinUJgUeeSp3Z4vXjIBNBUKMPB135tWw9RGiVbW8saBmg==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@angular/compiler-cli": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", - "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.1.4.tgz", + "integrity": "sha512-I603/3EmclgX4VUryBo3bxlF+8+fVucrW/V0leqNlt72ppFTphDiKiopogoJFWJxuULTo2V+7Koq8Em7kUO67Q==", + "license": "MIT", "dependencies": { - "@babel/core": "7.26.9", + "@babel/core": "7.28.0", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", - "yargs": "^17.2.1" + "yargs": "^18.0.0" }, "bin": { "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js", - "ngcc": "bundles/ngcc/index.js" + "ngc": "bundles/src/bin/ngc.js" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "19.2.5", - "typescript": ">=5.5 <5.9" + "@angular/compiler": "20.1.4", + "typescript": ">=5.8 <5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@angular/compiler-cli/node_modules/chokidar": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", - "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "node_modules/@angular/compiler-cli/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@angular/compiler-cli/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@angular/compiler-cli/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", "dependencies": { - "readdirp": "^4.0.1" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=20" } }, - "node_modules/@angular/compiler-cli/node_modules/readdirp": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", - "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "node_modules/@angular/compiler-cli/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/@angular/compiler-cli/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">= 14.16.0" + "node": ">=18" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular/compiler-cli/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@angular/compiler-cli/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@angular/compiler-cli/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@angular/compiler-cli/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/@angular/core": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.5.tgz", - "integrity": "sha512-NNEz1sEZz1mBpgf6Tz3aJ9b8KjqpTiMYhHfCYA9h9Ipe4D8gUmOsvPHPK2M755OX7p7PmUmzp1XCUHYrZMVHRw==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.1.4.tgz", + "integrity": "sha512-aWDux64a9usuVU2SnF0epqjXAj8JO8jViUzZAJAuFKSCtkeNzqP+Z6DjkqsCKrNvGP7xkX1XhhepUygxgh7/6A==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { + "@angular/compiler": "20.1.4", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } } }, "node_modules/@angular/forms": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.5.tgz", - "integrity": "sha512-2Zvy3qK1kOxiAX9fdSaeG48q7oyO/4RlMYlg1w+ra9qX1SrgwF3OQ2P2Vs+ojg1AxN3z9xFp4aYaaID/G2LZAw==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.1.4.tgz", + "integrity": "sha512-5gUwcV+JpzJ2rSPo1nR6iNz2Dm3iRcVCvRTsVnKhFbZCIbGLihLpoCuittsgUY/C9wh/rnmXlatmLJ7giSuUZA==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "19.2.5", - "@angular/core": "19.2.5", - "@angular/platform-browser": "19.2.5", + "@angular/common": "20.1.4", + "@angular/core": "20.1.4", + "@angular/platform-browser": "20.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.5.tgz", - "integrity": "sha512-oAc19bubk6Z/2Vv6OkV0MsjdgC8cUaUwBmwdc6blFVe1NCX1KjdaqDyC2EQAO3nWfcdV4uvOOuu8myxB64bamw==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.1.4.tgz", + "integrity": "sha512-yDkQef11JBkVIRiaDA2Iq/GYcu0OK4NMun2r56jTW/Kq+LnKn5q/6usWcN5rbvg7kQpc1ZOxwDGMACiyIYWHmQ==", + "license": "MIT", "dependencies": { - "@babel/core": "7.26.9", + "@babel/core": "7.28.0", "@types/babel__core": "7.20.5", - "fast-glob": "3.3.3", - "yargs": "^17.2.1" + "tinyglobby": "^0.2.12", + "yargs": "^18.0.0" }, "bin": { "localize-extract": "tools/bundles/src/extract/cli.js", @@ -638,27 +1329,147 @@ "localize-translate": "tools/bundles/src/translate/cli.js" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "19.2.5", - "@angular/compiler-cli": "19.2.5" + "@angular/compiler": "20.1.4", + "@angular/compiler-cli": "20.1.4" + } + }, + "node_modules/@angular/localize/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@angular/localize/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@angular/localize/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@angular/localize/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/@angular/localize/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular/localize/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@angular/localize/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@angular/localize/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@angular/localize/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/@angular/platform-browser": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.5.tgz", - "integrity": "sha512-Lshy++X16cvl6OPvfzMySpsqEaCPKEJmDjz7q7oSt96oxlh6LvOeOUVLjsNyrNaIt9NadpWoqjlu/I9RTPJkpw==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.1.4.tgz", + "integrity": "sha512-z86NsGSwm5pXCACdWBbp7SC1Xn+UGvuoRqTsi0dNUXT/3WrP6MvZT3TfNKwM63GLUqFAICSt7uFXS84D72ukvA==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "19.2.5", - "@angular/common": "19.2.5", - "@angular/core": "19.2.5" + "@angular/animations": "20.1.4", + "@angular/common": "20.1.4", + "@angular/core": "20.1.4" }, "peerDependenciesMeta": { "@angular/animations": { @@ -667,75 +1478,80 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.5.tgz", - "integrity": "sha512-15in8u4552EcdWNTXY2h0MKuJbk3AuXwWr0zVTum4CfB/Ss2tNTrDEdWhgAbhnUI0e9jZQee/fhBbA1rleMYrA==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.1.4.tgz", + "integrity": "sha512-bH4CjZ2O2oqRaKd36Xe/EhZDHx769pPf9oR4oITsZJ10bIhkWcaG9pgaW+W1PGc+nMevVpJ7XfG9m9n6+3bEfw==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "19.2.5", - "@angular/compiler": "19.2.5", - "@angular/core": "19.2.5", - "@angular/platform-browser": "19.2.5" + "@angular/common": "20.1.4", + "@angular/compiler": "20.1.4", + "@angular/core": "20.1.4", + "@angular/platform-browser": "20.1.4" } }, "node_modules/@angular/router": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.5.tgz", - "integrity": "sha512-9pSfmdNXLjaOKj0kd4UxBC7sFdCFOnRGbftp397G3KWqsLsGSKmNFzqhXNeA5QHkaVxnpmpm8HzXU+zYV5JwSg==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.1.4.tgz", + "integrity": "sha512-Etd2V2Qw+clQhJORBm7tMphCCweLNKbZvUc+lh1r7yrbBPnZvK3yd69W9ZQoRzrSSI25VGQDyzQXgpLUlHoE+w==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "19.2.5", - "@angular/core": "19.2.5", - "@angular/platform-browser": "19.2.5", + "@angular/common": "20.1.4", + "@angular/core": "20.1.4", + "@angular/platform-browser": "20.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -764,14 +1580,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", - "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.10", - "@babel/types": "^7.26.10", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -779,24 +1596,26 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -809,30 +1628,42 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -841,15 +1672,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", @@ -863,47 +1685,52 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -912,58 +1739,46 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", - "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1001,13 +1816,14 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -1017,13 +1833,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1033,13 +1850,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1049,13 +1867,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1065,13 +1884,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1081,13 +1901,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1097,13 +1918,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -1113,13 +1935,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -1129,13 +1952,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1145,13 +1969,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1161,13 +1986,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1177,13 +2003,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1193,13 +2020,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1209,13 +2037,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1225,13 +2054,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1241,13 +2071,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1257,13 +2088,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1273,13 +2105,14 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1289,13 +2122,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1305,13 +2139,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1321,13 +2156,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1337,13 +2173,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -1353,13 +2190,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1369,13 +2207,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1385,13 +2224,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1401,16 +2241,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -1425,10 +2269,11 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -1439,10 +2284,11 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1453,6 +2299,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1461,19 +2308,21 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", - "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1561,12 +2410,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -1574,17 +2427,19 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -1592,9 +2447,10 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", - "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.0.0.tgz", + "integrity": "sha512-X48nISrSOa89zu2VMljC4XaRf8NmgTwQBVHfS2Nu5G00ZwM31oOVrAtGxZF3b6wDYf9lJsf/Eq4cCSFKIkOWPQ==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" } @@ -1672,14 +2528,15 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.4.tgz", - "integrity": "sha512-d30576EZdApjAMceijXA5jDzRQHT/MygbC+J8I7EqA6f/FRpYxlRtRJbHF8gHeWYeSdOuTEJqonn7QLB1ELezA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.9.tgz", + "integrity": "sha512-DBJBkzI5Wx4jFaYm221LHvAhpKYkhVS0k9plqHwaHhofGNxvYB7J3Bz8w+bFJ05zaMb0sZNHo4KdmENQFlNTuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/figures": "^1.0.12", + "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -1696,13 +2553,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", - "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.13.tgz", + "integrity": "sha512-EkCtvp67ICIVVzjsquUiVSd+V5HRGOGQfsqA4E4vMWhYnB7InUL0pa0TIWt1i+OfP16Gkds8CdIu6yGZwOM1Yw==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7" }, "engines": { "node": ">=18" @@ -1717,14 +2575,14 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.15", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", - "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", + "version": "10.1.14", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.14.tgz", + "integrity": "sha512-Ma+ZpOJPewtIYl6HZHZckeX1STvDnHTCB2GVINNUlSEn2Am6LddWwfPkIGY0IUFVjUUrr/93XlBwTK6mfLjf0A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.13", - "@inquirer/type": "^3.0.8", + "@inquirer/figures": "^1.0.12", + "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -1745,15 +2603,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.17.tgz", - "integrity": "sha512-r6bQLsyPSzbWrZZ9ufoWL+CztkSatnJ6uSxqd6N+o41EZC51sQeWOzI6s5jLb+xxTWxl7PlUppqm8/sow241gg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.14.tgz", + "integrity": "sha512-yd2qtLl4QIIax9DTMZ1ZN2pFrrj+yL3kgIWxm34SS6uwCr0sIhsNyudUjAo5q3TqI03xx4SEBkUJqZuAInp9uA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/external-editor": "^1.0.1", - "@inquirer/type": "^3.0.8" + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7", + "external-editor": "^3.1.0" }, "engines": { "node": ">=18" @@ -1768,13 +2626,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.11.tgz", - "integrity": "sha512-OZSUW4hFMW2TYvX/Sv+NnOZgO8CHT2TU1roUCUIF2T+wfw60XFRRp9MRUPCT06cRnKL+aemt2YmTWwt7rOrNEA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.16.tgz", + "integrity": "sha512-oiDqafWzMtofeJyyGkb1CTPaxUkjIcSxePHHQCfif8t3HV9pHcw1Kgdw3/uGpDvaFfeTluwQtWiqzPVjAqS3zA==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -1789,32 +2648,10 @@ } } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", - "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.0", - "iconv-lite": "^0.6.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/figures": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", - "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz", + "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", "dev": true, "license": "MIT", "engines": { @@ -1822,13 +2659,14 @@ } }, "node_modules/@inquirer/input": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.8.tgz", - "integrity": "sha512-WXJI16oOZ3/LiENCAxe8joniNp8MQxF6Wi5V+EBbVA0ZIOpFcL4I9e7f7cXse0HJeIPCWO8Lcgnk98juItCi7Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.0.tgz", + "integrity": "sha512-opqpHPB1NjAmDISi3uvZOTrjEEU5CWVu/HBkDby8t93+6UxYX0Z7Ps0Ltjm5sZiEbWenjubwUkivAEYQmy9xHw==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5" + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7" }, "engines": { "node": ">=18" @@ -1843,13 +2681,14 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.11.tgz", - "integrity": "sha512-pQK68CsKOgwvU2eA53AG/4npRTH2pvs/pZ2bFvzpBhrznh8Mcwt19c+nMO7LHRr3Vreu1KPhNBF3vQAKrjIulw==", + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.16.tgz", + "integrity": "sha512-kMrXAaKGavBEoBYUCgualbwA9jWUx2TjMA46ek+pEKy38+LFpL9QHlTd8PO2kWPUgI/KB+qi02o4y2rwXbzr3Q==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5" + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7" }, "engines": { "node": ">=18" @@ -1864,13 +2703,14 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.11.tgz", - "integrity": "sha512-dH6zLdv+HEv1nBs96Case6eppkRggMe8LoOTl30+Gq5Wf27AO/vHFgStTVz4aoevLdNXqwE23++IXGw4eiOXTg==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.16.tgz", + "integrity": "sha512-g8BVNBj5Zeb5/Y3cSN+hDUL7CsIFDIuVxb9EPty3lkxBaYpjL5BNRKSYOF9yOLe+JOcKFd+TSVeADQ4iSY7rbg==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2" }, "engines": { @@ -1886,21 +2726,22 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", - "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.6.0.tgz", + "integrity": "sha512-jAhL7tyMxB3Gfwn4HIJ0yuJ5pvcB5maYUcouGcgd/ub79f9MqZ+aVnBtuFf+VC2GTkCBF+R+eo7Vi63w5VZlzw==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^4.1.2", - "@inquirer/confirm": "^5.1.6", - "@inquirer/editor": "^4.2.7", - "@inquirer/expand": "^4.0.9", - "@inquirer/input": "^4.1.6", - "@inquirer/number": "^3.0.9", - "@inquirer/password": "^4.0.9", - "@inquirer/rawlist": "^4.0.9", - "@inquirer/search": "^3.0.9", - "@inquirer/select": "^4.0.9" + "@inquirer/checkbox": "^4.1.9", + "@inquirer/confirm": "^5.1.13", + "@inquirer/editor": "^4.2.14", + "@inquirer/expand": "^4.0.16", + "@inquirer/input": "^4.2.0", + "@inquirer/number": "^3.0.16", + "@inquirer/password": "^4.0.16", + "@inquirer/rawlist": "^4.1.4", + "@inquirer/search": "^3.0.16", + "@inquirer/select": "^4.2.4" }, "engines": { "node": ">=18" @@ -1915,13 +2756,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.11.tgz", - "integrity": "sha512-uAYtTx0IF/PqUAvsRrF3xvnxJV516wmR6YVONOmCWJbbt87HcDHLfL9wmBQFbNJRv5kCjdYKrZcavDkH3sVJPg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.4.tgz", + "integrity": "sha512-5GGvxVpXXMmfZNtvWw4IsHpR7RzqAR624xtkPd1NxxlV5M+pShMqzL4oRddRkg8rVEOK9fKdJp1jjVML2Lr7TQ==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/type": "^3.0.7", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -1937,14 +2779,15 @@ } }, "node_modules/@inquirer/search": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.11.tgz", - "integrity": "sha512-9CWQT0ikYcg6Ls3TOa7jljsD7PgjcsYEM0bYE+Gkz+uoW9u8eaJCRHJKkucpRE5+xKtaaDbrND+nPDoxzjYyew==", + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.16.tgz", + "integrity": "sha512-POCmXo+j97kTGU6aeRjsPyuCpQQfKcMXdeTMw708ZMtWrj5aykZvlUxH4Qgz3+Y1L/cAVZsSpA+UgZCu2GMOMg==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/figures": "^1.0.12", + "@inquirer/type": "^3.0.7", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -1960,14 +2803,15 @@ } }, "node_modules/@inquirer/select": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.1.0.tgz", - "integrity": "sha512-z0a2fmgTSRN+YBuiK1ROfJ2Nvrpij5lVN3gPDkQGhavdvIVGHGW29LwYZfM/j42Ai2hUghTI/uoBuTbrJk42bA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.2.4.tgz", + "integrity": "sha512-unTppUcTjmnbl/q+h8XeQDhAqIOmwWYWNyiiP2e3orXrg6tOaa5DHXja9PChCSbChOsktyKgOieRZFnajzxoBg==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.5", + "@inquirer/core": "^10.1.14", + "@inquirer/figures": "^1.0.12", + "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -1984,9 +2828,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", - "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz", + "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", "dev": true, "license": "MIT", "engines": { @@ -2001,18 +2845,33 @@ } } }, - "node_modules/@iplab/ngx-file-upload": { - "version": "19.0.3", - "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-19.0.3.tgz", - "integrity": "sha512-PXQroFbMrQwg69b/j6Im9R8DkLz15YxiA0ATlFpOTPRtDhAWQMIRNdxbcqRLmBLdPvrsXpH/gN30f0GyC1k/fw==", + "node_modules/@iplab/ngx-color-picker": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@iplab/ngx-color-picker/-/ngx-color-picker-20.0.0.tgz", + "integrity": "sha512-nxPMHl+MZgH7HHLMi8lC+Se+K5EGKpAvmc7Oub191h9VvQj+C+cIFdxJ/KfWET3NFiff6DTbpgfgskjck32tzA==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/animations": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/forms": "^19.0.0", + "@angular/animations": "^20.0.0", + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0" + } + }, + "node_modules/@iplab/ngx-file-upload": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-20.0.0.tgz", + "integrity": "sha512-cBUg9Y3WgIWcumS6ei6JX77EaZL9lR/jsu9T4FEE0kbZd6N3xcwFYscWcqNHHRhImeKHPHFMfVU4oW9CuiDFxQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^20.0.0", + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/forms": "^20.0.0", "rxjs": "^7.0.0" } }, @@ -2110,6 +2969,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^7.0.4" }, @@ -2127,16 +2987,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2147,23 +3004,16 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2248,10 +3098,11 @@ } }, "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", - "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz", + "integrity": "sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ==", "dev": true, + "license": "MIT", "dependencies": { "@inquirer/type": "^1.5.5" }, @@ -2267,6 +3118,7 @@ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", "dev": true, + "license": "MIT", "dependencies": { "mute-stream": "^1.0.0" }, @@ -2279,100 +3131,183 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", "dev": true, + "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", - "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.4.1.tgz", + "integrity": "sha512-kKeP5PaY3bFrrF6GY5aDd96iuh1eoS+5CHJ+7hIP629KIEwzGNwbIzBmEX9TAhRJOivSRDTHCIsbu//+NsYKkg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", - "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.4.1.tgz", + "integrity": "sha512-9CMB3seTyHs3EOVWdKiB8IIEDBJ3Gq00Tqyi0V7DS3HL90BjM/AkbZGuhzXwPrfeFazR24SKaRrUQF74f+CmWw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", - "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.4.1.tgz", + "integrity": "sha512-1Mi69vU0akHgCI7tF6YbimPaNEKJiBm/p5A+aM8egr0joj27cQmCCOm2mZQ+Ht2BqmCfZaIgQnMg4gFYNMlpCA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", - "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.4.1.tgz", + "integrity": "sha512-d0vuXOdoKjHHJYZ/CRWopnkOiUpev+bgBBW+1tXtWsYWUj8uxl9ZmTBEmsL5mjUlpQDrlYiJSrhOU1hg5QWBSw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", - "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.4.1.tgz", + "integrity": "sha512-00RbEpvfnyPodlICiGFuiOmyvWaL9nzCRSqZz82BVFsGTiSQnnF0gpD1C8tO6OvtptELbtRuM7BS9f97LcowZw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.4.1.tgz", + "integrity": "sha512-4h8tm3i1ODf+28UyqQZLP7c2jmRM26AyEEyYp994B4GiBdGvGAsYUu3oiHANYK9xFpvLuFzyGeqFm1kdNC0D1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", - "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.4.1.tgz", + "integrity": "sha512-HqqKIhTbq6piJhkJpTTf3w1m/CgrmwXRAL9R9j7Ru5xdZSeO7Mg4AWiBC9B00uXR+LvVZKtUyRMVZfhmIZztmQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@microsoft/signalr": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", - "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", + "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", "eventsource": "^2.0.2", "fetch-cookie": "^2.0.3", "node-fetch": "^2.6.7", - "ws": "^7.4.5" + "ws": "^7.5.10" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.3.tgz", + "integrity": "sha512-bGwA78F/U5G2jrnsdRkPY3IwIwZeWUEfb5o764b79lb0rJmMT76TLwKhdNZOWakOQtedYefwIR4emisEMvInKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -2381,6 +3316,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2394,6 +3330,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2407,6 +3344,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2420,6 +3358,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2433,6 +3372,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2446,16 +3386,18 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@napi-rs/nice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", - "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz", + "integrity": "sha512-Sqih1YARrmMoHlXGgI9JrrgkzxcaaEso0AH+Y7j8NHonUs+xe4iDsgC3IBIDNdzEewbNpccNN6hip+b5vmyRLw==", "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">= 10" @@ -2465,32 +3407,33 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.0.1", - "@napi-rs/nice-android-arm64": "1.0.1", - "@napi-rs/nice-darwin-arm64": "1.0.1", - "@napi-rs/nice-darwin-x64": "1.0.1", - "@napi-rs/nice-freebsd-x64": "1.0.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", - "@napi-rs/nice-linux-arm64-gnu": "1.0.1", - "@napi-rs/nice-linux-arm64-musl": "1.0.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", - "@napi-rs/nice-linux-s390x-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-gnu": "1.0.1", - "@napi-rs/nice-linux-x64-musl": "1.0.1", - "@napi-rs/nice-win32-arm64-msvc": "1.0.1", - "@napi-rs/nice-win32-ia32-msvc": "1.0.1", - "@napi-rs/nice-win32-x64-msvc": "1.0.1" + "@napi-rs/nice-android-arm-eabi": "1.0.4", + "@napi-rs/nice-android-arm64": "1.0.4", + "@napi-rs/nice-darwin-arm64": "1.0.4", + "@napi-rs/nice-darwin-x64": "1.0.4", + "@napi-rs/nice-freebsd-x64": "1.0.4", + "@napi-rs/nice-linux-arm-gnueabihf": "1.0.4", + "@napi-rs/nice-linux-arm64-gnu": "1.0.4", + "@napi-rs/nice-linux-arm64-musl": "1.0.4", + "@napi-rs/nice-linux-ppc64-gnu": "1.0.4", + "@napi-rs/nice-linux-riscv64-gnu": "1.0.4", + "@napi-rs/nice-linux-s390x-gnu": "1.0.4", + "@napi-rs/nice-linux-x64-gnu": "1.0.4", + "@napi-rs/nice-linux-x64-musl": "1.0.4", + "@napi-rs/nice-win32-arm64-msvc": "1.0.4", + "@napi-rs/nice-win32-ia32-msvc": "1.0.4", + "@napi-rs/nice-win32-x64-msvc": "1.0.4" } }, "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", - "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.4.tgz", + "integrity": "sha512-OZFMYUkih4g6HCKTjqJHhMUlgvPiDuSLZPbPBWHLjKmFTv74COzRlq/gwHtmEVaR39mJQ6ZyttDl2HNMUbLVoA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2500,13 +3443,14 @@ } }, "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", - "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.4.tgz", + "integrity": "sha512-k8u7cjeA64vQWXZcRrPbmwjH8K09CBnNaPnI9L1D5N6iMPL3XYQzLcN6WwQonfcqCDv5OCY3IqX89goPTV4KMw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2516,13 +3460,14 @@ } }, "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-GsLdQvUcuVzoyzmtjsThnpaVEizAqH5yPHgnsBmq3JdVoVZHELFo7PuJEdfOH1DOHi2mPwB9sCJEstAYf3XCJA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2532,13 +3477,14 @@ } }, "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", - "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.4.tgz", + "integrity": "sha512-1y3gyT3e5zUY5SxRl3QDtJiWVsbkmhtUHIYwdWWIQ3Ia+byd/IHIEpqAxOGW1nhhnIKfTCuxBadHQb+yZASVoA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2548,13 +3494,14 @@ } }, "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.4.tgz", + "integrity": "sha512-06oXzESPRdXUuzS8n2hGwhM2HACnDfl3bfUaSqLGImM8TA33pzDXgGL0e3If8CcFWT98aHows5Lk7xnqYNGFeA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2564,13 +3511,14 @@ } }, "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.4.tgz", + "integrity": "sha512-CgklZ6g8WL4+EgVVkxkEvvsi2DSLf9QIloxWO0fvQyQBp6VguUSX3eHLeRpqwW8cRm2Hv/Q1+PduNk7VK37VZw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2580,13 +3528,14 @@ } }, "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.4.tgz", + "integrity": "sha512-wdAJ7lgjhAlsANUCv0zi6msRwq+D4KDgU+GCCHssSxWmAERZa2KZXO0H2xdmoJ/0i03i6YfK/sWaZgUAyuW2oQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2596,13 +3545,14 @@ } }, "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.4.tgz", + "integrity": "sha512-4b1KYG+sriufhFrpUS9uNOEYYJqSfcbnwGx6uGX7JjrH8tELG90cOpCawz5THNIwlS3DhLgnCOcn0+4p6z26QA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2612,13 +3562,14 @@ } }, "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.4.tgz", + "integrity": "sha512-iaf3vMRgr23oe1PUaKpxaH3DS0IMN0+N9iEiWVwYPm/U15vZFYdqVegGfN2PzrZLUl5lc8ZxbmEKDfuqslhAMA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2628,13 +3579,14 @@ } }, "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", - "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.4.tgz", + "integrity": "sha512-UXoREY6Yw6rHrGuTwQgBxpfjK34t6mTjibE9/cXbefL9AuUCJ9gEgwNKZiONuR5QGswChqo9cnthjdKkYyAdDg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2644,13 +3596,14 @@ } }, "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.4.tgz", + "integrity": "sha512-eFbgYCRPmsqbYPAlLYU5hYTNbogmIDUvknilehHsFhCH1+0/kN87lP+XaLT0Yeq4V/rpwChSd9vlz4muzFArtw==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2660,13 +3613,14 @@ } }, "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.4.tgz", + "integrity": "sha512-4T3E6uTCwWT6IPnwuPcWVz3oHxvEp/qbrCxZhsgzwTUBEwu78EGNXGdHfKJQt3soth89MLqZJw+Zzvnhrsg1mQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2676,13 +3630,14 @@ } }, "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.4.tgz", + "integrity": "sha512-NtbBkAeyBPLvCBkWtwkKXkNSn677eaT0cX3tygq+2qVv71TmHgX4gkX6o9BXjlPzdgPGwrUudavCYPT9tzkEqQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2692,13 +3647,14 @@ } }, "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.4.tgz", + "integrity": "sha512-vubOe3i+YtSJGEk/++73y+TIxbuVHi+W8ZzrRm2eETCjCRwNlgbfToQZ85dSA+4iBB/NJRGNp+O4hfdbbttZWA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2708,13 +3664,14 @@ } }, "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", - "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.4.tgz", + "integrity": "sha512-BMOVrUDZeg1RNRKVlh4eyLv5djAAVLiSddfpuuQ47EFjBcklg0NUeKMFKNrKQR4UnSn4HAiACLD7YK7koskwmg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2724,13 +3681,14 @@ } }, "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.4.tgz", + "integrity": "sha512-kCNk6HcRZquhw/whwh4rHsdPyOSCQCgnVDVik+Y9cuSVTDy3frpiCJTScJqPPS872h4JgZKkr/+CwcwttNEo9Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2740,17 +3698,18 @@ } }, "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-18.0.0.tgz", - "integrity": "sha512-GeSAz4yiGq49psdte8kcf+Y562wB3jK/qKRAkh6iA32lcXmy2sfQXVAmlHdjZ3AyP+E8lf3yMwuPdSKiYcDgSg==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-19.0.1.tgz", + "integrity": "sha512-1lErAkwh0F+gWkzpiddViY4GfA9LVXkwLpgBsV9Mb3IC0zo6WNkY8WxCC+LqajirBTu20DCkZSqeRzrwaVLpZw==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/forms": "^19.0.0", - "@angular/localize": "^19.0.0", + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/forms": "^20.0.0", + "@angular/localize": "^20.0.0", "@popperjs/core": "^2.11.8", "rxjs": "^6.5.3 || ^7.4.0" } @@ -2759,6 +3718,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2771,6 +3731,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "engines": { "node": ">= 8" } @@ -2779,6 +3740,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2792,6 +3754,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, + "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", @@ -2807,13 +3770,15 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@npmcli/fs": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, + "license": "ISC", "dependencies": { "semver": "^7.3.5" }, @@ -2826,6 +3791,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/promise-spawn": "^8.0.0", "ini": "^5.0.0", @@ -2845,6 +3811,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=16" } @@ -2853,13 +3820,15 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@npmcli/git/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^3.1.1" }, @@ -2875,6 +3844,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", "dev": true, + "license": "ISC", "dependencies": { "npm-bundled": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" @@ -2891,15 +3861,17 @@ "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/package-json": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", - "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", + "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/git": "^6.0.0", "glob": "^10.2.2", @@ -2918,6 +3890,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", "dev": true, + "license": "ISC", "dependencies": { "which": "^5.0.0" }, @@ -2930,6 +3903,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=16" } @@ -2939,6 +3913,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^3.1.1" }, @@ -2950,10 +3925,11 @@ } }, "node_modules/@npmcli/redact": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", - "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.2.2.tgz", + "integrity": "sha512-7VmYAmk4csGv08QzrDKScdzn11jHPFGyqJW39FyPgPuAp3zIaUmuCo1yxw9aGs+NEJuTGQ9Gwqpt93vtJubucg==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -2963,6 +3939,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/node-gyp": "^4.0.0", "@npmcli/package-json": "^6.0.0", @@ -2980,6 +3957,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=16" } @@ -2989,6 +3967,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^3.1.1" }, @@ -3005,6 +3984,7 @@ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { "detect-libc": "^1.0.3", @@ -3043,6 +4023,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -3063,6 +4044,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3083,6 +4065,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3103,6 +4086,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -3123,6 +4107,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3143,6 +4128,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3163,6 +4149,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3183,6 +4170,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3203,6 +4191,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3223,6 +4212,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3243,6 +4233,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3263,6 +4254,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3283,6 +4275,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3300,6 +4293,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "dev": true, + "license": "Apache-2.0", "optional": true, "bin": { "detect-libc": "bin/detect-libc.js" @@ -3313,6 +4307,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, + "license": "MIT", "optional": true }, "node_modules/@pkgjs/parseargs": { @@ -3340,264 +4335,298 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", - "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", - "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", - "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", - "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", - "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", - "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", - "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", - "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", - "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", - "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", - "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", - "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", - "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", - "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", - "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", - "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", - "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", - "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", - "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@schematics/angular": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.6.tgz", - "integrity": "sha512-fmbF9ONmEZqxHocCwOSWG2mHp4a22d1uW+DZUBUgZSBUFIrnFw42deOxDq8mkZOZ1Tc73UpLN2GKI7iJeUqS2A==", + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.1.4.tgz", + "integrity": "sha512-TNpm15NKf4buxPYnGaB3JY2B/3sbL19SdlpPDxkgyVY8WDDeZX95m3Tz2qlKpsYxy2XCGUj4Sxh7zJNGC9e/4g==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.6", - "@angular-devkit/schematics": "19.2.6", + "@angular-devkit/core": "20.1.4", + "@angular-devkit/schematics": "20.1.4", "jsonc-parser": "3.3.1" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } @@ -3606,6 +4635,7 @@ "version": "22.4.1", "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-22.4.1.tgz", "integrity": "sha512-Z19zaxu7tpwMHWc1h5Om9/sZJ39MWTQypju6T6WH7QIkelKgZE7DbYk3siD41vkR/62vT+q0Z1voC2OyxgRX9g==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, @@ -3621,6 +4651,7 @@ "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.4.0" }, @@ -3633,15 +4664,17 @@ "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.0.tgz", - "integrity": "sha512-o09cLSIq9EKyRXwryWDOJagkml9XgQCoCSRjHOnHLnvsivaW7Qznzz6yjfV7PHJHhIvyp8OH7OX8w0Dc5bQK7A==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.3.tgz", + "integrity": "sha512-fk2zjD9117RL9BjqEwF7fwv7Q/P9yGsMV4MUJZ/DocaQJ6+3pKr+syBq1owU5Q5qGw5CUbXzm+4yJ2JVRDQeSA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -3651,6 +4684,7 @@ "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", @@ -3664,12 +4698,13 @@ } }, "node_modules/@sigstore/tuf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", - "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.1.tgz", + "integrity": "sha512-eFFvlcBIoGwVkkwmTi/vEQFSva3xs5Ot3WmBcjgjVdiaoelBLQaQ/ZBfhlG0MnG0cmTYScPpk7eDdGDWUcFUmg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/protobuf-specs": "^0.4.1", "tuf-js": "^3.0.1" }, "engines": { @@ -3677,23 +4712,25 @@ } }, "node_modules/@sigstore/verify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", - "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.1.tgz", + "integrity": "sha512-hVJD77oT67aowHxwT4+M6PGOp+E2LtLdTK3+FC0lBO9T7sYwItDMXZ7Z07IDCvR1M717a4axbIWckrW67KMP/w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0" + "@sigstore/protobuf-specs": "^0.4.1" }, "engines": { "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@swimlane/ngx-charts": { - "version": "22.0.0-alpha.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-22.0.0-alpha.0.tgz", - "integrity": "sha512-sauI4QcfpuKXmRWajpeVtAoT7z8uI3u1+hvfcsJ796LRr06C676dkjoZsk7aX3EU+6uF8mJpXClOT/JcfnZrEA==", + "version": "23.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-23.0.0-alpha.0.tgz", + "integrity": "sha512-3ENgHscwVrTR1ARIGktZbfpx+MIMM/ikwrioOFDhj4crXU3ZvT9xAn59gn3dYOvfuiKIgWNC+TxlRnoPeCvoAw==", + "license": "MIT", "dependencies": { "d3-array": "^3.2.0", "d3-brush": "^3.0.0", @@ -3712,13 +4749,13 @@ "tslib": "^2.3.1" }, "peerDependencies": { - "@angular/animations": "17.x || 18.x || 19.x", - "@angular/cdk": "17.x || 18.x || 19.x", - "@angular/common": "17.x || 18.x || 19.x", - "@angular/core": "17.x || 18.x || 19.x", - "@angular/forms": "17.x || 18.x || 19.x", - "@angular/platform-browser": "17.x || 18.x || 19.x", - "@angular/platform-browser-dynamic": "17.x || 18.x || 19.x", + "@angular/animations": "18.x || 19.x || 20.x", + "@angular/cdk": "18.x || 19.x || 20.x", + "@angular/common": "18.x || 19.x || 20.x", + "@angular/core": "18.x || 19.x || 20.x", + "@angular/forms": "18.x || 19.x || 20.x", + "@angular/platform-browser": "18.x || 19.x || 20.x", + "@angular/platform-browser-dynamic": "18.x || 19.x || 20.x", "rxjs": "7.x" } }, @@ -3751,6 +4788,7 @@ "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", "dev": true, + "license": "MIT", "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -3760,6 +4798,7 @@ "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", "dev": true, + "license": "MIT", "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^9.0.5" @@ -4064,10 +5103,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/file-saver": { "version": "2.0.7", @@ -4085,7 +5125,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/luxon": { "version": "3.6.2", @@ -4093,13 +5134,21 @@ "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", "dev": true }, - "node_modules/@types/node": { - "version": "22.13.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", - "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", + "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", + "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/tinycolor2": { @@ -4108,20 +5157,21 @@ "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", - "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/type-utils": "8.28.0", - "@typescript-eslint/utils": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4131,21 +5181,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", - "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "engines": { @@ -4160,14 +5221,37 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", - "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0" + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4177,16 +5261,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", - "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.28.0", - "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4201,10 +5304,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", - "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4214,19 +5318,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", - "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/visitor-keys": "8.28.0", + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4240,15 +5347,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", - "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.28.0", - "@typescript-eslint/types": "8.28.0", - "@typescript-eslint/typescript-estree": "8.28.0" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4263,13 +5371,14 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", - "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.28.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4280,10 +5389,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4292,15 +5402,16 @@ } }, "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", - "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", + "integrity": "sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + "vite": "^6.0.0 || ^7.0.0" } }, "node_modules/@yarnpkg/lockfile": { @@ -4310,10 +5421,11 @@ "dev": true }, "node_modules/abbrev": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", - "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -4329,11 +5441,26 @@ "node": ">=6.5" } }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -4401,11 +5528,37 @@ } } }, + "node_modules/algoliasearch": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.32.0.tgz", + "integrity": "sha512-84xBncKNPBK8Ae89F65+SyVcOihrIbm/3N7to+GpRBHEUXGjA3ydWTMpcRW6jmFzkBQ/eqYy/y+J+NBpJWYjBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-abtesting": "5.32.0", + "@algolia/client-analytics": "5.32.0", + "@algolia/client-common": "5.32.0", + "@algolia/client-insights": "5.32.0", + "@algolia/client-personalization": "5.32.0", + "@algolia/client-query-suggestions": "5.32.0", + "@algolia/client-search": "5.32.0", + "@algolia/ingestion": "1.32.0", + "@algolia/monitoring": "1.32.0", + "@algolia/recommend": "5.32.0", + "@algolia/requester-browser-xhr": "5.32.0", + "@algolia/requester-fetch": "5.32.0", + "@algolia/requester-node-http": "5.32.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -4492,16 +5645,17 @@ ] }, "node_modules/beasties": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.2.0.tgz", - "integrity": "sha512-Ljqskqx/tbZagIglYoJIMzH5zgssyp+in9+9sAyh15N22AornBeIDnb8EZ6Rk+6ShfMxd92uO3gfpT0NtZbpow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.3.4.tgz", + "integrity": "sha512-NmzN1zN1cvGccXFyZ73335+ASXwBlVWcUPssiUDIlFdfyatHPRRufjCd5w8oPaQPvVnf9ELklaCGb1gi9FBwIw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "css-select": "^5.1.0", "css-what": "^6.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "htmlparser2": "^9.1.0", + "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3" @@ -4520,11 +5674,33 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/bootstrap": { "version": "5.3.3", @@ -4556,6 +5732,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4623,11 +5800,22 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", @@ -4651,6 +5839,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -4659,13 +5848,15 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/cacache/node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, + "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -4681,6 +5872,7 @@ "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", "dev": true, + "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -4698,10 +5890,42 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4745,22 +5969,39 @@ } }, "node_modules/chardet": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", - "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true, "license": "MIT" }, "node_modules/charts.css": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/charts.css/-/charts.css-1.1.0.tgz", - "integrity": "sha512-K1Qyb8ZKsu5cDrVbZeHECk/xSq6iOl8IDTR35uaMdhr/Vyyxvg9nYQy3KNB3aidxJ2E251afX5q2725N0uL3Vw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/charts.css/-/charts.css-1.2.0.tgz", + "integrity": "sha512-/NCeCMOYZHHeS5vR/32Ubl84Ob500SGriFUxUReMYFPnBIsaBcfO91EQsRHp6/1LwraXTqizCx+iSQ7XSsy4WA==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -4777,9 +6018,10 @@ } }, "node_modules/cli-spinners": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", - "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", "engines": { "node": ">=6" }, @@ -4792,6 +6034,7 @@ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, + "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" @@ -4808,6 +6051,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -4819,13 +6063,15 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cli-truncate/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -4843,6 +6089,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4858,6 +6105,7 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, + "license": "ISC", "engines": { "node": ">= 12" } @@ -4919,7 +6167,8 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", @@ -4927,11 +6176,68 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -4977,10 +6283,11 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -4993,10 +6300,11 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -5225,9 +6533,10 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -5257,16 +6566,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-it": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/detect-it/-/detect-it-4.0.1.tgz", "integrity": "sha512-dg5YBTJYvogK1+dA2mBUDKzOWfYZtHVba89SyZUhc4+e3i2tzgjANFg5lDRCd3UOtRcw00vUTMK8LELcMdicug==" }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "dev": true, + "license": "Apache-2.0", "optional": true, "engines": { "node": ">=8" @@ -5294,6 +6614,7 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -5303,14 +6624,6 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom7": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.6.tgz", - "integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==", - "dependencies": { - "ssr-window": "^4.0.0" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -5321,13 +6634,15 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -5343,6 +6658,7 @@ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -5352,6 +6668,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -5363,6 +6694,13 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.46", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.46.tgz", @@ -5373,6 +6711,16 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -5386,6 +6734,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "engines": { "node": ">=0.12" }, @@ -5398,6 +6747,7 @@ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5407,6 +6757,7 @@ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5418,7 +6769,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/error-ex": { "version": "1.3.2", @@ -5428,12 +6780,46 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", - "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -5441,31 +6827,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.1", - "@esbuild/android-arm": "0.25.1", - "@esbuild/android-arm64": "0.25.1", - "@esbuild/android-x64": "0.25.1", - "@esbuild/darwin-arm64": "0.25.1", - "@esbuild/darwin-x64": "0.25.1", - "@esbuild/freebsd-arm64": "0.25.1", - "@esbuild/freebsd-x64": "0.25.1", - "@esbuild/linux-arm": "0.25.1", - "@esbuild/linux-arm64": "0.25.1", - "@esbuild/linux-ia32": "0.25.1", - "@esbuild/linux-loong64": "0.25.1", - "@esbuild/linux-mips64el": "0.25.1", - "@esbuild/linux-ppc64": "0.25.1", - "@esbuild/linux-riscv64": "0.25.1", - "@esbuild/linux-s390x": "0.25.1", - "@esbuild/linux-x64": "0.25.1", - "@esbuild/netbsd-arm64": "0.25.1", - "@esbuild/netbsd-x64": "0.25.1", - "@esbuild/openbsd-arm64": "0.25.1", - "@esbuild/openbsd-x64": "0.25.1", - "@esbuild/sunos-x64": "0.25.1", - "@esbuild/win32-arm64": "0.25.1", - "@esbuild/win32-ia32": "0.25.1", - "@esbuild/win32-x64": "0.25.1" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -5476,20 +6862,28 @@ "node": ">=6" } }, - "node_modules/eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, + "license": "MIT" + }, + "node_modules/eslint": { + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.31.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5500,9 +6894,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5537,10 +6931,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -5603,10 +6998,11 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5706,14 +7102,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5723,10 +7120,11 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5776,6 +7174,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -5788,7 +7196,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "license": "MIT" }, "node_modules/eventsource": { "version": "2.0.2", @@ -5798,11 +7206,109 @@ "node": ">=12.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", - "dev": true + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/external-editor/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -5810,10 +7316,18 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5847,10 +7361,25 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-cookie": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", @@ -5881,6 +7410,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5888,6 +7418,24 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -5922,6 +7470,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -5940,6 +7508,7 @@ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, @@ -5958,6 +7527,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -5995,7 +7565,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -6003,6 +7573,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.3.12", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", @@ -6028,6 +7637,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -6039,14 +7649,20 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/graceful-fs": { @@ -6091,6 +7707,19 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6104,10 +7733,11 @@ } }, "node_modules/hosted-git-info": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", - "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, @@ -6119,7 +7749,8 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/html-escaper": { "version": "2.0.2", @@ -6128,9 +7759,9 @@ "dev": true }, "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -6139,24 +7770,67 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -6224,6 +7898,7 @@ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", "dev": true, + "license": "ISC", "dependencies": { "minimatch": "^9.0.0" }, @@ -6232,10 +7907,11 @@ } }, "node_modules/immutable": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", - "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", - "dev": true + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "dev": true, + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -6288,6 +7964,7 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -6305,6 +7982,7 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "dev": true, + "license": "MIT", "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -6313,6 +7991,16 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6337,6 +8025,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6353,6 +8042,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6372,10 +8062,18 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6513,12 +8211,14 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -6537,6 +8237,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", "dev": true, + "license": "MIT", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -6598,7 +8299,8 @@ "dev": true, "engines": [ "node >= 0.2.0" - ] + ], + "license": "MIT" }, "node_modules/karma-coverage": { "version": "2.2.1", @@ -6662,10 +8364,11 @@ } }, "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", "dev": true, + "license": "MIT", "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -6683,6 +8386,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6695,6 +8399,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6706,13 +8411,15 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6730,6 +8437,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -6745,6 +8453,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -6758,11 +8467,12 @@ } }, "node_modules/lmdb": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", - "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.1.tgz", + "integrity": "sha512-hoG9RIv42kdGJiieyElgWcKCTaw5S6Jqwyd1gLSVdsJ3+8MVm8e4yLronThiRJI9DazFAAs9xfB9nWeMQ2DWKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { "msgpackr": "^1.11.2", @@ -6775,14 +8485,37 @@ "download-lmdb-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.2.6", - "@lmdb/lmdb-darwin-x64": "3.2.6", - "@lmdb/lmdb-linux-arm": "3.2.6", - "@lmdb/lmdb-linux-arm64": "3.2.6", - "@lmdb/lmdb-linux-x64": "3.2.6", - "@lmdb/lmdb-win32-x64": "3.2.6" + "@lmdb/lmdb-darwin-arm64": "3.4.1", + "@lmdb/lmdb-darwin-x64": "3.4.1", + "@lmdb/lmdb-linux-arm": "3.4.1", + "@lmdb/lmdb-linux-arm64": "3.4.1", + "@lmdb/lmdb-linux-x64": "3.4.1", + "@lmdb/lmdb-win32-arm64": "3.4.1", + "@lmdb/lmdb-win32-x64": "3.4.1" } }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT", + "peer": true + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -6814,6 +8547,7 @@ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", @@ -6833,6 +8567,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, + "license": "MIT", "dependencies": { "environment": "^1.0.0" }, @@ -6848,6 +8583,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6860,6 +8596,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6872,6 +8609,7 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" }, @@ -6886,13 +8624,15 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "dev": true, + "license": "MIT", "dependencies": { "get-east-asian-width": "^1.0.0" }, @@ -6908,6 +8648,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" }, @@ -6923,6 +8664,7 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" @@ -6939,6 +8681,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" @@ -6955,6 +8698,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6972,6 +8716,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -6987,6 +8732,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -7003,14 +8749,16 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, "node_modules/luxon": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", - "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "license": "MIT", "engines": { "node": ">=12" } @@ -7050,6 +8798,7 @@ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", @@ -7067,10 +8816,44 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "engines": { "node": ">= 8" } @@ -7079,6 +8862,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -7091,6 +8875,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -7098,6 +8883,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -7111,6 +8919,7 @@ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -7145,6 +8954,7 @@ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, @@ -7157,6 +8967,7 @@ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, + "license": "MIT", "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", @@ -7174,6 +8985,7 @@ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -7186,6 +8998,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -7197,13 +9010,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -7216,6 +9031,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -7227,13 +9043,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -7246,6 +9064,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -7257,41 +9076,28 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/minizlib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", - "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" + "minipass": "^7.1.2" }, "engines": { "node": ">= 18" } }, - "node_modules/minizlib/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dev": true, - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -7314,10 +9120,11 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/msgpackr": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", - "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", + "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", "dev": true, + "license": "MIT", "optional": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" @@ -7329,6 +9136,7 @@ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { "node-gyp-build-optional-packages": "5.2.2" @@ -7350,6 +9158,7 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -7365,6 +9174,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -7383,6 +9193,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7427,29 +9238,17 @@ "@angular/core": ">=18.1.0 || >=19.0.0" } }, - "node_modules/ngx-color-picker": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-19.0.0.tgz", - "integrity": "sha512-jZs7nk/DJB6FryElYnfkojWYCgpEc650s800g+39ebocVMZ18fAHf/CQd5+Bdm4E3zoRod0a0sErJ+c8tGQcCg==", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": ">=9.0.0", - "@angular/core": ">=9.0.0", - "@angular/forms": ">=9.0.0" - } - }, "node_modules/ngx-extended-pdf-viewer": { - "version": "23.0.0-alpha.7", - "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz", - "integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-24.1.0.tgz", + "integrity": "sha512-tI1eqcULlDTo3QwfVl6cze3vRNSUVstt+uRHI/eK0X4unO8mwiNcW+RlixKy2uf5uBxbloNA3NzdymObI2nnxQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=17.0.0 <20.0.0", - "@angular/core": ">=17.0.0 <20.0.0" + "@angular/common": ">=17.0.0 <21.0.0", + "@angular/core": ">=17.0.0 <21.0.0" } }, "node_modules/ngx-file-drop": { @@ -7469,15 +9268,33 @@ } }, "node_modules/ngx-infinite-scroll": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-19.0.0.tgz", - "integrity": "sha512-Ft4xNNDLXoDGi2hF6ylehjxbG8JIgfoL6qDWWcebGMcbh1CEfEsh0HGkDuFlX/cBBMenRh2HFbXlYq8BAtbvLw==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-20.0.0.tgz", + "integrity": "sha512-Mh7lg85jeDPzZirxPHjyMagCcC3vbk+yPO5uoXHNkmGer8MrO6vOydOX306qY4QYDyMJ+ngIQgpl5HJXfc590A==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=19.0.0 <20.0.0", - "@angular/core": ">=19.0.0 <20.0.0" + "@angular/common": ">=20.0.0 <21.0.0", + "@angular/core": ">=20.0.0 <21.0.0" + } + }, + "node_modules/ngx-quill": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/ngx-quill/-/ngx-quill-28.0.1.tgz", + "integrity": "sha512-fbhPzAPkSSgTutkpXW46kVfP++m1yEAZUJ/kqLZ9DjkF50Ati8n7PJZw/pnxssQP4BRWAtoTypaHFzCkHNrzQw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "^20.0.0", + "quill": "^2.0.0", + "rxjs": "^7.0.0" } }, "node_modules/ngx-stars": { @@ -7510,6 +9327,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, + "license": "MIT", "optional": true }, "node_modules/node-fetch": { @@ -7532,20 +9350,21 @@ } }, "node_modules/node-gyp": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", - "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", + "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", "dev": true, + "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", + "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { @@ -7560,6 +9379,7 @@ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "detect-libc": "^2.0.1" @@ -7575,6 +9395,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -7584,6 +9405,7 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=16" } @@ -7593,6 +9415,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, + "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -7608,6 +9431,7 @@ "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", "dev": true, + "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -7625,6 +9449,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^3.1.1" }, @@ -7640,6 +9465,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } @@ -7654,6 +9480,7 @@ "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, + "license": "ISC", "dependencies": { "abbrev": "^3.0.0" }, @@ -7674,6 +9501,7 @@ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", "dev": true, + "license": "ISC", "dependencies": { "npm-normalize-package-bin": "^4.0.0" }, @@ -7686,6 +9514,7 @@ "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" }, @@ -7698,6 +9527,7 @@ "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -7707,6 +9537,7 @@ "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", "dev": true, + "license": "ISC", "dependencies": { "hosted-git-info": "^8.0.0", "proc-log": "^5.0.0", @@ -7718,15 +9549,16 @@ } }, "node_modules/npm-packlist": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", - "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.0.tgz", + "integrity": "sha512-rht9U6nS8WOBDc53eipZNPo5qkAV4X2rhKE2Oj1DYUQ3DieXfj0mKkVmjnf3iuNdtMd8WfLdi2L6ASkD/8a+Kg==", "dev": true, + "license": "ISC", "dependencies": { "ignore-walk": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm-pick-manifest": { @@ -7734,6 +9566,7 @@ "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", "dev": true, + "license": "ISC", "dependencies": { "npm-install-checks": "^7.1.0", "npm-normalize-package-bin": "^4.0.0", @@ -7749,6 +9582,7 @@ "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/redact": "^3.0.0", "jsonparse": "^1.3.1", @@ -7768,6 +9602,7 @@ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -7775,6 +9610,42 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7846,17 +9717,29 @@ } }, "node_modules/ordered-binary": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", - "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", + "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", "dev": true, + "license": "MIT", "optional": true }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-map": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -7865,10 +9748,11 @@ } }, "node_modules/pacote": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", - "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.0.tgz", + "integrity": "sha512-lcqexq73AMv6QNLo7SOpz0JJoaGdS3rBFgF122NZVl1bApo2mfu+XzUBU/X/XsiJu+iUmKpekRayqQYAs+PhkA==", "dev": true, + "license": "ISC", "dependencies": { "@npmcli/git": "^6.0.0", "@npmcli/installed-package-contents": "^3.0.0", @@ -7879,7 +9763,7 @@ "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", + "npm-packlist": "^10.0.0", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^18.0.0", "proc-log": "^5.0.0", @@ -7892,9 +9776,16 @@ "pacote": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7937,6 +9828,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, "dependencies": { "entities": "^4.4.0" }, @@ -7945,12 +9837,13 @@ } }, "node_modules/parse5-html-rewriting-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", - "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.1.0.tgz", + "integrity": "sha512-2ifK6Jb+ONoqOy5f+cYHsqvx1obHQdvIk13Jmt/5ezxP0U9p+fqd+R6O73KblGswyuzBYfetmsfK9ThMgnuPPg==", "dev": true, + "license": "MIT", "dependencies": { - "entities": "^4.3.0", + "entities": "^6.0.0", "parse5": "^7.0.0", "parse5-sax-parser": "^7.0.0" }, @@ -7958,11 +9851,25 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parse5-sax-parser": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", "dev": true, + "license": "MIT", "dependencies": { "parse5": "^7.0.0" }, @@ -7970,6 +9877,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8016,6 +9933,16 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8033,7 +9960,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, "engines": { "node": ">=12" }, @@ -8042,18 +9968,32 @@ } }, "node_modules/piscina": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", - "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.2.tgz", + "integrity": "sha512-9cE/BTA/xhDiyNUEj6EKWLEQC17fh/24ydYzQwcA7QdYh75K6kzL2GHvxDF5i9rFGtUaaKk7/u4xp07qiKXccQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, "optionalDependencies": { "@napi-rs/nice": "^1.0.1" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -8069,8 +10009,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8082,7 +10023,8 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -8098,6 +10040,7 @@ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -8107,6 +10050,7 @@ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, + "license": "MIT", "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -8115,6 +10059,20 @@ "node": ">=10" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -8128,6 +10086,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -8137,6 +10111,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -8152,6 +10127,63 @@ } ] }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8165,6 +10197,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8279,6 +10324,7 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -8287,6 +10333,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -8296,15 +10343,17 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/rollup": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", - "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -8314,32 +10363,51 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.8", - "@rollup/rollup-android-arm64": "4.34.8", - "@rollup/rollup-darwin-arm64": "4.34.8", - "@rollup/rollup-darwin-x64": "4.34.8", - "@rollup/rollup-freebsd-arm64": "4.34.8", - "@rollup/rollup-freebsd-x64": "4.34.8", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", - "@rollup/rollup-linux-arm-musleabihf": "4.34.8", - "@rollup/rollup-linux-arm64-gnu": "4.34.8", - "@rollup/rollup-linux-arm64-musl": "4.34.8", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", - "@rollup/rollup-linux-riscv64-gnu": "4.34.8", - "@rollup/rollup-linux-s390x-gnu": "4.34.8", - "@rollup/rollup-linux-x64-gnu": "4.34.8", - "@rollup/rollup-linux-x64-musl": "4.34.8", - "@rollup/rollup-win32-arm64-msvc": "4.34.8", - "@rollup/rollup-win32-ia32-msvc": "4.34.8", - "@rollup/rollup-win32-x64-msvc": "4.34.8", + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -8392,10 +10460,11 @@ "devOptional": true }, "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -8411,34 +10480,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/screenfull": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-6.0.2.tgz", @@ -8451,9 +10492,10 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -8461,11 +10503,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8485,6 +10573,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8501,6 +10665,7 @@ "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^3.1.0", "@sigstore/core": "^2.0.0", @@ -8532,6 +10697,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" @@ -8548,6 +10714,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8560,6 +10727,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8572,16 +10740,18 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", "dev": true, + "license": "MIT", "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -8596,6 +10766,7 @@ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -8619,6 +10790,7 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -8647,6 +10819,7 @@ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -8656,13 +10829,15 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true + "dev": true, + "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, + "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -8672,24 +10847,22 @@ "version": "3.0.21", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", - "dev": true + "dev": true, + "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, - "node_modules/ssr-window": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz", - "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, @@ -8697,6 +10870,29 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8791,9 +10987,9 @@ } }, "node_modules/swiper": { - "version": "8.4.7", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.7.tgz", - "integrity": "sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==", + "version": "11.2.10", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.10.tgz", + "integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==", "funding": [ { "type": "patreon", @@ -8804,29 +11000,17 @@ "url": "http://opencollective.com/swiper" } ], - "hasInstallScript": true, - "dependencies": { - "dom7": "^4.0.4", - "ssr-window": "^4.0.2" - }, + "license": "MIT", "engines": { "node": ">= 4.7.0" } }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, + "license": "ISC", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -8844,6 +11028,7 @@ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "dev": true, + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -8856,6 +11041,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -8868,6 +11054,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=8" } @@ -8877,6 +11064,7 @@ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "dev": true, + "license": "MIT", "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -8890,6 +11078,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -8901,13 +11090,30 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinygradient": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", @@ -8917,10 +11123,24 @@ "tinycolor2": "^1.0.0" } }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -8928,6 +11148,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -9025,14 +11255,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", - "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", + "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", "dev": true, + "license": "MIT", "dependencies": { "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -9055,6 +11286,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -9062,10 +11294,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9075,16 +11324,18 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" }, "node_modules/unique-filename": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, + "license": "ISC", "dependencies": { "unique-slug": "^5.0.0" }, @@ -9097,6 +11348,7 @@ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, @@ -9112,6 +11364,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -9175,35 +11437,51 @@ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "node_modules/validate-npm-package-name": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", - "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.1.tgz", + "integrity": "sha512-OaI//3H0J7ZkR1OqlhGA8cA+Cbk/2xFOQpJOt5+s27/ta9eZwpeervh4Mxh4w0im/kdgktowaqVNR7QOrUd7Yg==", "dev": true, + "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", + "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "postcss": "^8.5.3", - "rollup": "^4.30.1" + "fdir": "^6.4.6", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -9212,14 +11490,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -9260,11 +11538,25 @@ } } }, - "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -9286,6 +11578,7 @@ "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", "dev": true, + "license": "MIT", "optional": true }, "node_modules/webidl-conversions": { @@ -9368,6 +11661,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9430,7 +11724,8 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", @@ -9483,6 +11778,7 @@ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -9490,10 +11786,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zone.js": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", - "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" } } } diff --git a/UI/Web/package.json b/UI/Web/package.json index 05d539aed..04f3ebaec 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -5,6 +5,7 @@ "ng": "ng", "start": "npm run cache-locale && ng serve --host 0.0.0.0", "build": "npm run cache-locale && ng build", + "build-backend": "ng build && rm -r ../../API/wwwroot/* && cp -r dist/browser/* ../../API/wwwroot", "minify-langs": "node minify-json.js", "cache-locale": "node hash-localization.js", "cache-locale-prime": "node hash-localization-prime.js", @@ -16,70 +17,72 @@ }, "private": true, "dependencies": { - "@angular-slider/ngx-slider": "^19.0.0", - "@angular/animations": "^19.2.5", - "@angular/cdk": "^19.2.8", - "@angular/common": "^19.2.5", - "@angular/compiler": "^19.2.5", - "@angular/core": "^19.2.5", - "@angular/forms": "^19.2.5", - "@angular/localize": "^19.2.5", - "@angular/platform-browser": "^19.2.5", - "@angular/platform-browser-dynamic": "^19.2.5", - "@angular/router": "^19.2.5", - "@fortawesome/fontawesome-free": "^6.7.2", + "@angular-slider/ngx-slider": "^20.0.0", + "@angular/animations": "^20.1.4", + "@angular/cdk": "^20.1.4", + "@angular/common": "^20.1.4", + "@angular/compiler": "^20.1.4", + "@angular/core": "^20.1.4", + "@angular/forms": "^20.1.4", + "@angular/localize": "^20.1.4", + "@angular/platform-browser": "^20.1.4", + "@angular/platform-browser-dynamic": "^20.1.4", + "@angular/router": "^20.1.4", + "@fortawesome/fontawesome-free": "^7.0.0", "@iharbeck/ngx-virtual-scroller": "^19.0.1", - "@iplab/ngx-file-upload": "^19.0.3", + "@iplab/ngx-color-picker": "^20.0.0", + "@iplab/ngx-file-upload": "^20.0.0", "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", - "@microsoft/signalr": "^8.0.7", - "@ng-bootstrap/ng-bootstrap": "^18.0.0", + "@microsoft/signalr": "^9.0.6", + "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.7", "@siemens/ngx-datatable": "^22.4.1", - "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@swimlane/ngx-charts": "^23.0.0-alpha.0", "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", - "charts.css": "^1.1.0", + "charts.css": "^1.2.0", "file-saver": "^2.0.5", - "luxon": "^3.6.1", + "luxon": "^3.7.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", "ng-select2-component": "^17.2.4", - "ngx-color-picker": "^19.0.0", - "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", + "ngx-extended-pdf-viewer": "^24.1.0", "ngx-file-drop": "^16.0.0", + "ngx-quill": "^28.0.1", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", "rxjs": "^7.8.2", "screenfull": "^6.0.2", - "swiper": "^8.4.6", + "swiper": "^11.2.10", "tslib": "^2.8.1", - "zone.js": "^0.15.0" + "zone.js": "^0.15.1" }, "devDependencies": { - "@angular-eslint/builder": "^19.3.0", - "@angular-eslint/eslint-plugin": "^19.3.0", - "@angular-eslint/eslint-plugin-template": "^19.3.0", - "@angular-eslint/schematics": "^19.3.0", - "@angular-eslint/template-parser": "^19.3.0", - "@angular/build": "^19.2.6", - "@angular/cli": "^19.2.6", - "@angular/compiler-cli": "^19.2.5", + "@angular-eslint/builder": "^20.1.1", + "@angular-eslint/eslint-plugin": "^20.1.1", + "@angular-eslint/eslint-plugin-template": "^20.1.1", + "@angular-eslint/schematics": "^20.1.1", + "@angular-eslint/template-parser": "^20.1.1", + "@angular/build": "^20.1.4", + "@angular/cli": "^20.1.4", + "@angular/compiler-cli": "^20.1.4", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.6.2", - "@types/node": "^22.13.13", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", - "eslint": "^9.23.0", + "@types/marked": "^5.0.2", + "@types/node": "^24.0.14", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^9.31.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.5.4", + "typescript": "^5.8.3", "webpack-bundle-analyzer": "^4.10.2" } } diff --git a/UI/Web/src/_quill-theme.scss b/UI/Web/src/_quill-theme.scss new file mode 100644 index 000000000..0c12fa3e0 --- /dev/null +++ b/UI/Web/src/_quill-theme.scss @@ -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); + //} +} diff --git a/UI/Web/src/app/_directives/long-click.directive.ts b/UI/Web/src/app/_directives/long-click.directive.ts new file mode 100644 index 000000000..594685667 --- /dev/null +++ b/UI/Web/src/app/_directives/long-click.directive.ts @@ -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(); + } + } + +} diff --git a/UI/Web/src/app/_models/common/i-has-reading-time.ts b/UI/Web/src/app/_models/common/i-has-reading-time.ts index 41753d1fd..3c6a25dd3 100644 --- a/UI/Web/src/app/_models/common/i-has-reading-time.ts +++ b/UI/Web/src/app/_models/common/i-has-reading-time.ts @@ -4,5 +4,4 @@ export interface IHasReadingTime { avgHoursToRead: number; pages: number; wordCount: number; - } diff --git a/UI/Web/src/app/_models/events/annotation-update-event.ts b/UI/Web/src/app/_models/events/annotation-update-event.ts new file mode 100644 index 000000000..dd1e4faa1 --- /dev/null +++ b/UI/Web/src/app/_models/events/annotation-update-event.ts @@ -0,0 +1,5 @@ +import {Annotation} from "../../book-reader/_models/annotations/annotation"; + +export interface AnnotationUpdateEvent { + annotation: Annotation; +} diff --git a/UI/Web/src/app/_models/manga-reader/bookmark-info.ts b/UI/Web/src/app/_models/manga-reader/bookmark-info.ts index fa3a5de99..c3418c7ff 100644 --- a/UI/Web/src/app/_models/manga-reader/bookmark-info.ts +++ b/UI/Web/src/app/_models/manga-reader/bookmark-info.ts @@ -1,6 +1,6 @@ -import { FileDimension } from "src/app/manga-reader/_models/file-dimension"; -import { LibraryType } from "../library/library"; -import { MangaFormat } from "../manga-format"; +import {FileDimension} from "src/app/manga-reader/_models/file-dimension"; +import {LibraryType} from "../library/library"; +import {MangaFormat} from "../manga-format"; export interface BookmarkInfo { seriesName: string; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 886c570e2..f68a33504 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,5 +1,6 @@ import {PageLayoutMode} from '../page-layout-mode'; import {SiteTheme} from './site-theme'; +import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot"; export interface Preferences { @@ -12,6 +13,7 @@ export interface Preferences { collapseSeriesRelationships: boolean; shareReviews: boolean; locale: string; + bookReaderHighlightSlots: HighlightSlot[]; // Kavita+ aniListScrobblingEnabled: boolean; diff --git a/UI/Web/src/app/_models/readers/page-bookmark.ts b/UI/Web/src/app/_models/readers/page-bookmark.ts index 68feee118..e8a8c4a26 100644 --- a/UI/Web/src/app/_models/readers/page-bookmark.ts +++ b/UI/Web/src/app/_models/readers/page-bookmark.ts @@ -1,10 +1,25 @@ import {Series} from "../series"; export interface PageBookmark { - id: number; - page: number; - seriesId: number; - volumeId: number; - chapterId: number; - series: Series; + id: number; + page: number; + seriesId: number; + volumeId: number; + chapterId: number; + /** + * Only present on epub-based Bookmarks + */ + imageOffset: number; + /** + * Only present on epub-based Bookmarks + */ + xPath: string | null; + /** + * This is only used when getting all bookmarks. + */ + series: Series | null; + /** + * Chapter name (from ToC) or Title (from ComicInfo/PDF) + */ + chapterTitle: string | null; } diff --git a/UI/Web/src/app/_models/readers/personal-toc.ts b/UI/Web/src/app/_models/readers/personal-toc.ts index 3d4c3c9af..b0ce7aa20 100644 --- a/UI/Web/src/app/_models/readers/personal-toc.ts +++ b/UI/Web/src/app/_models/readers/personal-toc.ts @@ -3,6 +3,9 @@ export interface PersonalToC { pageNumber: number; title: string; bookScrollId: string | undefined; + selectedText: string | null; + chapterTitle: string | null; /* Ui Only */ position: 0; + } diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts index 7391cdad9..f8a0ecdae 100644 --- a/UI/Web/src/app/_models/search/search-result-group.ts +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -1,36 +1,39 @@ -import { Chapter } from "../chapter"; -import { Library } from "../library/library"; -import { MangaFile } from "../manga-file"; -import { SearchResult } from "./search-result"; -import { Tag } from "../tag"; +import {Chapter} from "../chapter"; +import {Library} from "../library/library"; +import {MangaFile} from "../manga-file"; +import {SearchResult} from "./search-result"; +import {Tag} from "../tag"; import {BookmarkSearchResult} from "./bookmark-search-result"; import {Genre} from "../metadata/genre"; import {ReadingList} from "../reading-list"; import {UserCollection} from "../collection-tag"; import {Person} from "../metadata/person"; +import {Annotation} from "../../book-reader/_models/annotations/annotation"; export class SearchResultGroup { - libraries: Array = []; - series: Array = []; - collections: Array = []; - readingLists: Array = []; - persons: Array = []; - genres: Array = []; - tags: Array = []; - files: Array = []; - chapters: Array = []; - bookmarks: Array = []; + libraries: Array = []; + series: Array = []; + collections: Array = []; + readingLists: Array = []; + persons: Array = []; + genres: Array = []; + tags: Array = []; + files: Array = []; + chapters: Array = []; + bookmarks: Array = []; + annotations: Array = []; - reset() { - this.libraries = []; - this.series = []; - this.collections = []; - this.readingLists = []; - this.persons = []; - this.genres = []; - this.tags = []; - this.files = []; - this.chapters = []; - this.bookmarks = []; - } + reset() { + this.libraries = []; + this.series = []; + this.collections = []; + this.readingLists = []; + this.persons = []; + this.genres = []; + this.tags = []; + this.files = []; + this.chapters = []; + this.bookmarks = []; + this.annotations = []; + } } diff --git a/UI/Web/src/app/_pipes/highlight-color.pipe.ts b/UI/Web/src/app/_pipes/highlight-color.pipe.ts new file mode 100644 index 000000000..f831d0695 --- /dev/null +++ b/UI/Web/src/app/_pipes/highlight-color.pipe.ts @@ -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'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/page-chapter-label.pipe.ts b/UI/Web/src/app/_pipes/page-chapter-label.pipe.ts new file mode 100644 index 000000000..f8c446f97 --- /dev/null +++ b/UI/Web/src/app/_pipes/page-chapter-label.pipe.ts @@ -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}); + } + +} diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts index 43ac41c86..5dd04dc75 100644 --- a/UI/Web/src/app/_pipes/read-time-left.pipe.ts +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -1,7 +1,6 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {TranslocoService} from "@jsverse/transloco"; import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; -import {DecimalPipe} from "@angular/common"; @Pipe({ name: 'readTimeLeft', @@ -11,10 +10,10 @@ export class ReadTimeLeftPipe implements PipeTransform { constructor(private readonly translocoService: TranslocoService) {} - transform(readingTimeLeft: HourEstimateRange): string { + transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string { const hoursLabel = readingTimeLeft.avgHours > 1 - ? this.translocoService.translate('read-time-pipe.hours') - : this.translocoService.translate('read-time-pipe.hour'); + ? this.translocoService.translate(`read-time-pipe.hours${includeLeftLabel ? '-left' : ''}`) + : this.translocoService.translate(`read-time-pipe.hour${includeLeftLabel ? '-left' : ''}`); const formattedHours = this.customRound(readingTimeLeft.avgHours); diff --git a/UI/Web/src/app/_pipes/slot-color.pipe.ts b/UI/Web/src/app/_pipes/slot-color.pipe.ts new file mode 100644 index 000000000..665404897 --- /dev/null +++ b/UI/Web/src/app/_pipes/slot-color.pipe.ts @@ -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})`; + } + +} diff --git a/UI/Web/src/app/_services/annotation.service.ts b/UI/Web/src/app/_services/annotation.service.ts new file mode 100644 index 000000000..cb18eb0e8 --- /dev/null +++ b/UI/Web/src/app/_services/annotation.service.ts @@ -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([]); + /** + * Annotations for a given book + */ + public readonly annotations = this._annotations.asReadonly(); + + private _events = signal(null); + public readonly events = this._events.asReadonly(); + + private readonly user = signal(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>(this.baseUrl + 'annotation/all?chapterId=' + chapterId).pipe(map(annotations => { + this._annotations.set(annotations); + return annotations; + })); + } + + + createAnnotation(data: Annotation) { + return this.httpClient.post(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(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(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 } }); + } +} diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 88cbd7460..73228467b 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -1,7 +1,8 @@ -import { Injectable, Inject } from '@angular/core'; -import { DOCUMENT } from '@angular/common'; +import {inject, Injectable} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; import {NavigationEnd, Router} from "@angular/router"; +import {environment} from "../../environments/environment"; interface ColorSpace { primary: string; @@ -30,6 +31,9 @@ const colorScapeSelector = 'colorscape'; providedIn: 'root' }) export class ColorscapeService { + private readonly document = inject(DOCUMENT); + private readonly router = inject(Router); + private colorSubject = new BehaviorSubject(null); private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null); public readonly colors$ = this.colorSubject.asObservable(); @@ -37,8 +41,12 @@ export class ColorscapeService { private minDuration = 1000; // minimum duration private maxDuration = 4000; // maximum duration private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace + // Use 0.179 as threshold (roughly equivalent to #767676) + // This gives better visual results than 0.5 + public static readonly defaultLuminanceThreshold = 0.179; - constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { + + constructor() { this.router.events.pipe( filter(event => event instanceof NavigationEnd), tap(() => this.checkAndResetColorscapeAfterDelay()) @@ -46,6 +54,100 @@ export class ColorscapeService { } + /** + * Returns a fitting text color depending on the background color of the element + * style.backgroundColor **must** be set on the passed element for this to work + * @param el + */ + getContrastingTextColor(el: HTMLElement): string { + const style = window.getComputedStyle(el); + const bgColor = style.backgroundColor; + + if (bgColor === '') { + return 'black'; + } + + const rgba = this.rgbStringToRGBA(bgColor); + const luminance = this.getLuminance(rgba); + + return luminance > ColorscapeService.defaultLuminanceThreshold ? 'black' : 'white'; + } + + getLuminance(rgba: RGBAColor): number { + // Convert RGB to relative luminance with gamma correction + const getRelativeLuminance = (color: number): number => { + const c = color / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + + const r = getRelativeLuminance(rgba.r); + const g = getRelativeLuminance(rgba.g); + const b = getRelativeLuminance(rgba.b); + + // WCAG relative luminance formula (https://www.w3.org/WAI/GL/wiki/Relative_luminance) + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + /** + * Generates a base64 encoding for an Image. Used in manual file upload flow. + * @param img + * @returns + */ + getBase64Image(img: HTMLImageElement) { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d", {alpha: false}); + if (!ctx) { + return ''; + } + + ctx.drawImage(img, 0, 0); + return canvas.toDataURL("image/png"); + } + + getAverageColour(img: HTMLImageElement, sampleWidth?: number, sampleHeight?: number): RGBAColor | undefined { + let canvas = document.createElement('canvas'); + let ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const sw = sampleWidth ?? 20; + const sh = sampleHeight ?? 20; + let imageData: ImageData; + try { + imageData = ctx.getImageData( + canvas.width -sw, + canvas.height - sh, + sw, + sh + ); + } catch (e) { + return; + } + + let r = 0, g = 0, b = 0; + const pixels = imageData.data; + + for (let i = 0; i < pixels.length; i += 4) { + r += pixels[i]; + g += pixels[i + 1]; + b += pixels[i + 2]; + } + + const pixelCount = pixels.length / 4; + return { + r: Math.round(r / pixelCount), + g: Math.round(g / pixelCount), + b: Math.round(b / pixelCount), + a: 0.5, + }; + } + /** * Due to changing ColorScape on route end, we might go from one space to another, but the router events resets to default * This delays it to see if the colors changed or not in 500ms and if not, then we will reset to default. @@ -153,7 +255,7 @@ export class ColorscapeService { } } - private hexToRGBA(hex: string, opacity: number = 1): RGBAColor { + public hexToRGBA(hex: string, opacity: number = 1): RGBAColor { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { diff --git a/UI/Web/src/app/_services/epub-highlight.service.ts b/UI/Web/src/app/_services/epub-highlight.service.ts new file mode 100644 index 000000000..4b87f5bc8 --- /dev/null +++ b/UI/Web/src/app/_services/epub-highlight.service.ts @@ -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, + { + 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); + } + } + } +} diff --git a/UI/Web/src/app/_services/epub-reader-menu.service.ts b/UI/Web/src/app/_services/epub-reader-menu.service.ts new file mode 100644 index 000000000..e3811a6d7 --- /dev/null +++ b/UI/Web/src/app/_services/epub-reader-menu.service.ts @@ -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(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); + } + + + +} diff --git a/UI/Web/src/app/_services/epub-reader-settings.service.ts b/UI/Web/src/app/_services/epub-reader-settings.service.ts new file mode 100644 index 000000000..fd4fb83f6 --- /dev/null +++ b/UI/Web/src/app/_services/epub-reader-settings.service.ts @@ -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; + bookReaderLineSpacing: FormControl; + bookReaderFontSize: FormControl; + bookReaderFontFamily: FormControl; + bookReaderTapToPaginate: FormControl; + bookReaderReadingDirection: FormControl; + bookReaderWritingStyle: FormControl; + bookReaderThemeName: FormControl; + bookReaderLayoutMode: FormControl; + bookReaderImmersiveMode:FormControl ; +}> + +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(null); + private readonly _parentReadingProfile = signal(null); + private readonly _currentSeriesId = signal(null); + private readonly _isInitialized = signal(false); + + // Settings signals + private readonly _pageStyles = signal(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements + private readonly _readingDirection = signal(ReadingDirection.LeftToRight); + private readonly _writingStyle = signal(WritingStyle.Horizontal); + private readonly _activeTheme = signal(undefined); + private readonly _clickToPaginate = signal(false); + private readonly _layoutMode = signal(BookPageLayoutMode.Default); + private readonly _immersiveMode = signal(false); + private readonly _isFullscreen = signal(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(); + + // 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 { + 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 { + 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")); + }); + } +} diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 86aa8872a..8c559b726 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,8 +1,8 @@ import {DestroyRef, inject, Injectable} from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { ThemeService } from './theme.service'; -import { RecentlyAddedItem } from '../_models/recently-added-item'; -import { AccountService } from './account.service'; +import {environment} from 'src/environments/environment'; +import {ThemeService} from './theme.service'; +import {RecentlyAddedItem} from '../_models/recently-added-item'; +import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Injectable({ @@ -93,8 +93,8 @@ export class ImageService { return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`; } - getBookmarkedImage(chapterId: number, pageNum: number) { - return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`; + getBookmarkedImage(chapterId: number, pageNum: number, imageOffset: number = 0) { + return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}&imageOffset=${imageOffset}`; } getWebLinkImage(url: string) { diff --git a/UI/Web/src/app/_services/layout-measurement.service.ts b/UI/Web/src/app/_services/layout-measurement.service.ts new file mode 100644 index 000000000..c21238def --- /dev/null +++ b/UI/Web/src/app/_services/layout-measurement.service.ts @@ -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(); + + private readingSectionElement?: HTMLElement; + private bookContentElement?: HTMLElement; + + // Public signals for components to consume + readonly measurements = signal({ + 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 = {}; + + 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 = {}; + + 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); + } + } +} diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index f870d1449..1ffb887fe 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -11,6 +11,8 @@ import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; +import {AnnotationUpdateEvent} from "../_models/events/annotation-update-event"; +import {toSignal} from "@angular/core/rxjs-interop"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -118,7 +120,11 @@ export enum EVENTS { /** * A Rate limit error was hit when matching a series with Kavita+ */ - ExternalMatchRateLimitError = 'ExternalMatchRateLimitError' + ExternalMatchRateLimitError = 'ExternalMatchRateLimitError', + /** + * Annotation is updated within the reader + */ + AnnotationUpdate = 'AnnotationUpdate', } export interface Message { @@ -140,11 +146,13 @@ export class MessageHubService { /** * Any events that come from the backend */ - public messages$ = this.messagesSource.asObservable(); + public readonly messages$ = this.messagesSource.asObservable(); + public readonly messageSignal = toSignal(this.messages$); /** * Users that are online */ public onlineUsers$ = this.onlineUsersSource.asObservable(); + public readonly onlineUsersSignal = toSignal(this.onlineUsers$); constructor() {} @@ -248,6 +256,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.AnnotationUpdate, resp => { + this.messagesSource.next({ + event: EVENTS.AnnotationUpdate, + payload: resp.body as AnnotationUpdateEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ event: EVENTS.NotificationProgress, diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 52aef2a4a..6c9c600b9 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,5 +1,5 @@ import {HttpClient} from '@angular/common/http'; -import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; +import {DestroyRef, inject, Injectable} from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; import {Router} from '@angular/router'; import {environment} from 'src/environments/environment'; @@ -40,6 +40,8 @@ export class ReaderService { private readonly location = inject(Location); private readonly accountService = inject(AccountService); private readonly toastr = inject(ToastrService); + private readonly httpClient = inject(HttpClient); + private readonly document = inject(DOCUMENT); baseUrl = environment.apiUrl; encodedKey: string = ''; @@ -50,7 +52,7 @@ export class ReaderService { private noSleep: NoSleep = new NoSleep(); - constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) { + constructor() { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (user) { this.encodedKey = encodeURIComponent(user.apiKey); @@ -100,12 +102,12 @@ export class ReaderService { return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`; } - bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) { - return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page}); + bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0, xpath: string | null = null) { + return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, imageNumber, xpath}); } - unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) { - return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); + unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) { + return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber}); } getAllBookmarks(filter: FilterV2 | undefined) { @@ -222,6 +224,10 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/time-left?seriesId=' + seriesId); } + getTimeLeftForChapter(seriesId: number, chapterId: number) { + return this.httpClient.get(this.baseUrl + `reader/time-left-for-chapter?seriesId=${seriesId}&chapterId=${chapterId}`); + } + /** * Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes */ @@ -309,13 +315,18 @@ export class ReaderService { /** * Closes the reader and causes a redirection */ - closeReader(readingListMode: boolean = false, readingListId: number = 0) { + closeReader(libraryId: number, seriesId: number, chapterId: number, readingListMode: boolean = false, readingListId: number = 0) { if (readingListMode) { this.router.navigateByUrl('lists/' + readingListId); - } else { - // TODO: back doesn't always work, it might be nice to check the pattern of the url and see if we can be smart before just going back - this.location.back(); + return } + + if (window.history.length > 1) { + this.location.back(); + return; + } + + this.router.navigateByUrl(`/library/${libraryId}/series/${seriesId}/chapter/${chapterId}`); } removePersonalToc(chapterId: number, pageNumber: number, title: string) { @@ -326,43 +337,256 @@ export class ReaderService { return this.httpClient.get>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId); } - createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) { - return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId}); + createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null, selectedText: string) { + return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText}); } + + getElementFromXPath(path: string) { - const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - if (node?.nodeType === Node.ELEMENT_NODE) { - return node as Element; + try { + const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (node?.nodeType === Node.ELEMENT_NODE) { + return node as Element; + } + return null; + } catch (e) { + console.debug("Failed to evaluate XPath:", path, " exception:", e) + return null; } - return null; } + /** + * Removes the Kavita UI aspect of the xpath from a given xpath variable + * Used for Annotations and Bookmarks within epub reader + * + * @param xpath + */ + descopeBookReaderXpath(xpath: string) { + if (xpath.startsWith("id(")) return xpath; + + const bookContentElement = this.document.querySelector('.book-content'); + if (!bookContentElement?.children[0]) { + console.warn('Book content element not found, returning original xpath'); + return xpath; + } + + const bookContentXPath = this.getXPathTo(bookContentElement.children[0], true); + + // Normalize both paths + const normalizedXpath = this.normalizeXPath(xpath); + const normalizedBookContentXPath = this.normalizeXPath(bookContentXPath); + + //console.log('Descoping - Original:', xpath); + //console.log('Descoping - Normalized xpath:', normalizedXpath); + //console.log('Descoping - Book content path:', normalizedBookContentXPath); + + // Find the UI container pattern and extract content path + const descopedPath = this.extractContentPath(normalizedXpath, normalizedBookContentXPath); + + //console.log('Descoped', xpath, 'to', descopedPath); + return descopedPath; + } + + /** + * Adds the Kavita UI aspect to the xpath so loading from xpath in the reader works + * @param xpath + */ + scopeBookReaderXpath(xpath: string) { + if (xpath.startsWith("id(")) return xpath; + + const bookContentElement = this.document.querySelector('.book-content'); + if (!bookContentElement?.children[0]) { + console.warn('Book content element not found, returning original xpath'); + return xpath; + } + + const bookContentXPath = this.getXPathTo(bookContentElement.children[0], true); + const normalizedXpath = this.normalizeXPath(xpath); + const normalizedBookContentXPath = this.normalizeXPath(bookContentXPath); + + // If already scoped, return as-is + if (normalizedXpath.includes(normalizedBookContentXPath)) { + return xpath; + } + + // Replace //body with the actual book content path + if (normalizedXpath.startsWith('//body')) { + const relativePath = normalizedXpath.substring(6); // Remove '//body' + return bookContentXPath + relativePath; + } + + // If it starts with /body, replace with book content path + if (normalizedXpath.startsWith('/body')) { + const relativePath = normalizedXpath.substring(5); // Remove '/body' + return bookContentXPath + relativePath; + } + + // Default: prepend the book content path + return bookContentXPath + (normalizedXpath.startsWith('/') ? normalizedXpath : '/' + normalizedXpath); + } + + /** + * Extract the content path by finding the UI container boundary + */ + private extractContentPath(fullXpath: string, bookContentXPath: string): string { + // Look for the pattern where the book content container ends + // The book content path should be a prefix of the full path + + // First, try direct substring match + if (fullXpath.startsWith(bookContentXPath)) { + const contentPath = fullXpath.substring(bookContentXPath.length); + return '//body' + (contentPath.startsWith('/') ? contentPath : '/' + contentPath); + } + + // If direct match fails, try to find the common UI structure pattern + // Look for the app-book-reader container end point + const readerPattern = /\/app-book-reader\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]\/div\[\d+\]/; + const match = fullXpath.match(readerPattern); + + if (match) { + const containerEndIndex = fullXpath.indexOf(match[0]) + match[0].length; + const contentPath = fullXpath.substring(containerEndIndex); + return '//body' + (contentPath.startsWith('/') ? contentPath : '/' + contentPath); + } + + // Alternative approach: look for the deepest common path structure + // Split both paths and find where they diverge after the UI container + const fullParts = fullXpath.split('/').filter(p => p.length > 0); + const bookParts = bookContentXPath.split('/').filter(p => p.length > 0); + + // Find the app-book-reader index in full path + const readerIndex = fullParts.findIndex(part => part.startsWith('app-book-reader')); + + if (readerIndex !== -1) { + // Look for the pattern after app-book-reader that matches book content structure + // Typically: app-book-reader[1]/div[1]/div[2]/div[3]/div[1]/div[1] then content starts + let contentStartIndex = readerIndex + 6; // Skip the typical 6 div containers + + // Adjust based on actual book content depth + const bookReaderIndex = bookParts.findIndex(part => part.startsWith('app-book-reader')); + if (bookReaderIndex !== -1) { + const expectedDepth = bookParts.length - bookReaderIndex - 1; + contentStartIndex = readerIndex + 1 + expectedDepth; + } + + if (contentStartIndex < fullParts.length) { + const contentParts = fullParts.slice(contentStartIndex); + return '//body/' + contentParts.join('/'); + } + } + + // Fallback: clean common UI prefixes + return this.cleanCommonUIPrefixes(fullXpath); + } + + /** + * Normalize XPath by cleaning common variations and converting to lowercase + */ + private normalizeXPath(xpath: string): string { + let normalized = xpath.toLowerCase(); + + // Remove common HTML document prefixes + const prefixesToRemove = [ + '//html[1]//body', + '//html[1]//app-root[1]', + '//html//body', + '//html//app-root[1]' + ]; + + for (const prefix of prefixesToRemove) { + if (normalized.startsWith(prefix)) { + normalized = '//body' + normalized.substring(prefix.length); + break; + } + } + + return normalized; + } + + /** + * Clean common UI prefixes that shouldn't be in descoped paths + */ + private cleanCommonUIPrefixes(xpath: string): string { + let cleaned = xpath; + + // Remove app-root references + cleaned = cleaned.replace(/\/app-root\[\d+\]/g, ''); + + // Ensure it starts with //body + if (!cleaned.startsWith('//body') && !cleaned.startsWith('/body')) { + // Try to find body in the path + const bodyIndex = cleaned.indexOf('/body'); + if (bodyIndex !== -1) { + cleaned = '//' + cleaned.substring(bodyIndex + 1); + } else { + // If no body found, assume it should start with //body + cleaned = '//body' + (cleaned.startsWith('/') ? cleaned : '/' + cleaned); + } + } + + return cleaned; + } + + /** * * @param element * @param pureXPath Will ignore shortcuts like id('') */ getXPathTo(element: any, pureXPath = false): string { - if (element === null) return ''; - if (!pureXPath) { - if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === document.body) { return element.tagName; } + if (!element) { + console.error('getXPathTo: element is null or undefined'); + return ''; } + let xpath = this.getXPath(element, pureXPath); - let ix = 0; - const siblings = element.parentNode?.childNodes || []; - for (let sibling of siblings) { + // Ensure xpath starts with // for absolute paths + if (xpath && !xpath.startsWith('//') && !xpath.startsWith('id(')) { + xpath = '//' + xpath; + } + + return xpath; + } + + private getXPath(element: HTMLElement, pureXPath = false): string { + if (!element) { + console.error('getXPath: element is null or undefined'); + return ''; + } + + // Handle shortcuts (unless pureXPath is requested) + if (!pureXPath && element.id) { + return `id("${element.id}")`; + } + + if (element === document.body) { + return 'body'; + } + + if (!element.parentNode) { + return element.tagName.toLowerCase(); + } + + // Count same-tag siblings + let siblingIndex = 1; + const siblings = Array.from(element.parentNode?.children ?? []); + const tagName = element.tagName; + + for (const sibling of siblings) { if (sibling === element) { - return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; + break; } - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { - ix++; + if (sibling.tagName === tagName) { + siblingIndex++; } - } - return ''; + + const currentPath = `${element.tagName.toLowerCase()}[${siblingIndex}]`; + const parentPath = this.getXPath(element.parentElement!, pureXPath); + + return parentPath ? `${parentPath}/${currentPath}` : currentPath; } readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) { @@ -390,4 +614,5 @@ export class ReaderService { this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format), {queryParams: {incognitoMode}}); } + } diff --git a/UI/Web/src/app/_services/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts index 3a0837962..ab6ec25aa 100644 --- a/UI/Web/src/app/_services/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -1,24 +1,56 @@ -import { ElementRef, Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter, ReplaySubject } from 'rxjs'; +import {ElementRef, inject, Injectable, signal} from '@angular/core'; +import {NavigationEnd, Router} from '@angular/router'; +import {filter, ReplaySubject} from 'rxjs'; + +const DEFAULT_TIMEOUT = 3000; +const DEFAULT_TOLERANCE = 3; +const DEFAULT_DEBOUNCE = 100; + +interface ScrollEndOptions { + tolerance?: number; + timeout?: number; + debounce?: number; +} + +interface ScrollToOptions { + scrollIntoViewOptions: ScrollIntoViewOptions; + timeout: number; +} + +interface ScrollHandler { + timeoutId?: number; + callback?: () => void; + targetPosition?: { x?: number; y?: number }; + tolerance: number; + cleanup?: () => void; +} @Injectable({ providedIn: 'root' }) export class ScrollService { - private scrollContainerSource = new ReplaySubject>(1); + private readonly router = inject(Router); + + private readonly debugMode = false; + + private readonly scrollContainerSource = new ReplaySubject>(1); /** * Exposes the current container on the active screen that is our primary overlay area. Defaults to 'body' and changes to 'body' on page loads */ - public scrollContainer$ = this.scrollContainerSource.asObservable(); + public readonly scrollContainer$ = this.scrollContainerSource.asObservable(); - constructor(router: Router) { + private activeScrollHandlers = new Map(); - router.events + private readonly _lock = signal(false); + public readonly isScrollingLock = this._lock.asReadonly(); + + constructor() { + this.router.events .pipe(filter(event => event instanceof NavigationEnd)) .subscribe(() => { this.scrollContainerSource.next('body'); + this.cleanup(); }); this.scrollContainerSource.next('body'); } @@ -38,18 +70,92 @@ export class ScrollService { || document.body.scrollLeft || 0); } - scrollTo(top: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'smooth') { - el.scroll({ - top: top, - behavior: behavior - }); + /** + * Returns true if the log is active + * @private + */ + private checkLock(): boolean { + + return false; // NOTE: We don't need locking anymore - it bugs out + + if (!this._lock()) return false; + + console.warn("[ScrollService] tried to scroll while locked, timings should be checked") + + return true; } - scrollToX(left: number, el: Element | Window = window, behavior: 'auto' | 'smooth' = 'auto') { - el.scroll({ - left: left, + private intersectionObserver(element: HTMLElement, callback?: () => void) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.executeCallback(element, callback); + } + }); + }, {threshold: 1.0}); + + observer.observe(element); + return observer; + } + + scrollIntoView(element: HTMLElement, options?: ScrollToOptions, callback?: () => void) { + if (this.checkLock()) return; + this._lock.set(true); + + const timeoutId = window.setTimeout(() => { + console.warn('Intersection observer timed out - forcing callback execution'); + this.executeCallback(element, callback) + }, DEFAULT_TIMEOUT) + + const observer = this.intersectionObserver(element, callback); + const scrollHandler: ScrollHandler = { + timeoutId: timeoutId, + callback: callback, + tolerance: 0, + cleanup: () => { + observer.disconnect(); + observer.unobserve(element); + clearTimeout(timeoutId); + }, + } + + this.activeScrollHandlers.set(element, scrollHandler); + if (options?.timeout || 0) { + setTimeout(() => element.scrollIntoView(options?.scrollIntoViewOptions), options?.timeout || 0); + } else { + element.scrollIntoView(options?.scrollIntoViewOptions); + } + + } + + scrollTo(position: number, element: HTMLElement, behavior: 'auto' | 'smooth' = 'smooth', + onComplete?: () => void, options?: ScrollEndOptions) { + if (this.checkLock()) return; + this._lock.set(true); + + element.scrollTo({ + top: position, behavior: behavior }); + + if (onComplete) { + this.onScrollEnd((element as HTMLElement), onComplete, { y: position }, options); + } + } + + scrollToX(position: number, element: HTMLElement, behavior: 'auto' | 'smooth' = 'auto', + onComplete?: () => void, options?: ScrollEndOptions) { + if (this.checkLock()) return; + this._lock.set(true); + + element.scrollTo({ + left: position, + behavior: behavior + }); + + if (onComplete) { + this.onScrollEnd((element as HTMLElement), onComplete, { x: position }, options); + } } setScrollContainer(elem: ElementRef | undefined) { @@ -57,4 +163,155 @@ export class ScrollService { this.scrollContainerSource.next(elem); } } + + /** + * Register scroll end callback + */ + private onScrollEnd( + element: HTMLElement, + callback: () => void, + targetPosition?: { x?: number; y?: number }, + options?: ScrollEndOptions + ): void { + const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE; + const timeout = options?.timeout ?? DEFAULT_TIMEOUT; + const debounce = options?.debounce ?? DEFAULT_DEBOUNCE; + + this.clearScrollHandler(element); + + let debounceTimer: number; + let scrollEventCount = 0; + + const checkComplete = () => { + const currentX = element.scrollLeft; + const currentY = element.scrollTop; + + if (targetPosition) { + let isComplete = true; + let deltaInfo: any = {}; + + if (targetPosition.x !== undefined) { + const deltaX = Math.abs(currentX - targetPosition.x); + deltaInfo.deltaX = deltaX; + if (deltaX > tolerance) { + isComplete = false; + } + } + if (targetPosition.y !== undefined) { + const deltaY = Math.abs(currentY - targetPosition.y); + deltaInfo.deltaY = deltaY; + if (deltaY > tolerance) { + isComplete = false; + } + } + + this.debugLog('Completion check:', { + isComplete, + ...deltaInfo, + tolerance + }); + + if (isComplete) { + this.debugLog('Scroll completed successfully'); + this.executeCallback(element, callback); + return; + } + } + }; + + const scrollHandler = () => { + scrollEventCount++; + this.debugLog(`Scroll event #${scrollEventCount}`); + + clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(() => { + this.debugLog('Scroll debounce timeout reached'); + checkComplete(); + + if (!targetPosition) { + this.debugLog('No target position - completing'); + this.executeCallback(element, callback); + } + }, debounce); + }; + + // Rest of your existing scroll handler setup... + const handlerData: ScrollHandler = { + callback, + targetPosition, + tolerance, + timeoutId: window.setTimeout(() => { + this.executeCallback(element, callback); + }, timeout) + }; + + this.activeScrollHandlers.set(element, handlerData); + element.addEventListener('scroll', scrollHandler, { passive: true }); + + handlerData.cleanup = () => { + this.debugLog('Cleaning up scroll handler'); + element.removeEventListener('scroll', scrollHandler); + clearTimeout(debounceTimer); + if (handlerData.timeoutId) { + clearTimeout(handlerData.timeoutId); + } + }; + + // Check immediately for instant scrolls + setTimeout(() => { + this.debugLog('Initial completion check'); + checkComplete(); + }, 50); + } + + private executeCallback(element: HTMLElement, callback?: () => void): void { + this._lock.set(false); + + this.clearScrollHandler(element); + + if (!callback) return; + + try { + callback(); + } catch (error) { + console.error('Error in scroll completion callback:', error); + } + } + + private clearScrollHandler(element: HTMLElement): void { + const handler = this.activeScrollHandlers.get(element); + if (!handler) return; + + this.activeScrollHandlers.delete(element); + if (handler.cleanup) { + handler.cleanup(); + } + } + + /** + * Clean up all handlers + */ + cleanup(): void { + this.activeScrollHandlers.forEach((handler, element) => { + this.clearScrollHandler(element); + }); + } + + /** + * Force unlocking of scroll lock + */ + unlock() { + this._lock.set(false); + this.cleanup(); + } + + private debugLog(message: string, extraData?: any) { + if (!this.debugMode) return; + + if (extraData !== undefined) { + console.log(message, extraData); + } else { + console.log(message); + } + } } diff --git a/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html new file mode 100644 index 000000000..0135af417 --- /dev/null +++ b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html @@ -0,0 +1,9 @@ +
+ + +
+ +
+
+
+
diff --git a/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.scss b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.ts b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.ts new file mode 100644 index 000000000..cf46374cf --- /dev/null +++ b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.ts @@ -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(); + +} diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html index 5a9804d54..195b52ca3 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -33,7 +33,7 @@
- +
diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index a40ea6729..485ffd113 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -16,7 +16,7 @@ {{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
- + {{t('include-type-tooltip')}} diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 7b30ccedd..485fe30f5 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,19 +1,12 @@ -import { - ChangeDetectionStrategy, - Component, - DestroyRef, effect, - HostListener, - inject, - OnInit -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject, OnInit} from '@angular/core'; import {NavigationStart, Router, RouterOutlet} from '@angular/router'; -import {map, shareReplay, take, tap} from 'rxjs/operators'; +import {map, shareReplay, take} from 'rxjs/operators'; import {AccountService} from './_services/account.service'; import {LibraryService} from './_services/library.service'; import {NavService} from './_services/nav.service'; import {NgbModal, NgbModalConfig, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap'; import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common'; -import {filter, interval, Observable, switchMap} from 'rxjs'; +import {filter, Observable} from 'rxjs'; import {ThemeService} from "./_services/theme.service"; import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.component'; import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component"; @@ -120,11 +113,15 @@ export class AppComponent implements OnInit { const user = this.accountService.currentUserSignal(); if (!user) return; + // Refresh the user data + this.accountService.refreshAccount().subscribe(account => { + if (this.accountService.hasAdminRole(user)) { + this.licenseService.licenseInfo().subscribe(); + } + }); + // Bootstrap anything that's needed this.themeService.getThemes().subscribe(); this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); - if (this.accountService.hasAdminRole(user)) { - this.licenseService.licenseInfo().subscribe(); - } } } diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html new file mode 100644 index 000000000..659cff3e6 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html @@ -0,0 +1,67 @@ + +
+
+
+ {{ annotation().ownerUsername }} +
+
{{ annotation().createdUtc | utcToLocaleDate | date: 'shortDate' }}
+
+ +
+ +
+

{{annotation().selectedText}}

+
+ +
+ @let content = annotation().comment; + @if (content !== '\"\"') { + + } @else { + {{null | defaultValue}} + } +
+
+ + +
+ +
diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss new file mode 100644 index 000000000..220abcc7f --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss @@ -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); +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts new file mode 100644 index 000000000..865bf7a6f --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts @@ -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(); + allowEdit = input(true); + showPageLink = input(true); + /** + * Redirects to the reader with annotation in view + */ + showInReaderLink = input(false); + isInReader = input(true); + @Output() delete = new EventEmitter(); + @Output() navigate = new EventEmitter(); + + titleColor: Signal; + + 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(); + }); + + } +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html new file mode 100644 index 000000000..fe5cb4922 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss new file mode 100644 index 000000000..6eea0479a --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss @@ -0,0 +1,10 @@ + +.icon-spacer { + font-size: 0.85rem; +} + +.epub-highlight { + position: relative; + display: inline; + transition: all 0.2s ease-in-out; +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts new file mode 100644 index 000000000..2ef83e7df --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts @@ -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(true); + color = input(HighlightColor.Blue); + + annotation = model.required(); + + @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, (_) => {}); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.html b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.html new file mode 100644 index 000000000..5a0b6f677 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.html @@ -0,0 +1,55 @@ + + +
+ @let collapsed = isCollapsed(); + + @if (desktopLayout() && canCollapse()) { + + } + +
+
+ @let activeSlot = selectedSlot(); + @if (activeSlot) { +
+ @for(slot of slots(); track slot.color; let index = $index) { + + } +
+ } + + @if (desktopLayout()) { + + } +
+
+
+
diff --git a/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.scss new file mode 100644 index 000000000..ea9a3466a --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.scss @@ -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; +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.ts new file mode 100644 index 000000000..e58facb74 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/highlight-bar/highlight-bar.component.ts @@ -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(); + isCollapsed = model(true); + canCollapse = model(true); + isEditMode = model(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; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html new file mode 100644 index 000000000..390d12d88 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html @@ -0,0 +1,20 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ @let sId = seriesId(); + @let rp = readingProfile(); + @if (sId && rp) { + + } +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts new file mode 100644 index 000000000..71be105fd --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts @@ -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(); + seriesId = model(); + readingProfile = model(); + readerSettingsService = model.required(); + + constructor() { + + effect(() => { + const id = this.chapterId(); + if (!id) { + console.error('You must pass chapterId'); + return; + } + }); + } + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html new file mode 100644 index 000000000..ccc26f8dc --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html @@ -0,0 +1,30 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + @if (annotations.length > FilterAfter) { +
+
+ +
+ +
+
+
+ } + + @for(annotation of annotations() | filter: filterList; track annotation.comment + annotation.highlightColor + annotation.containsSpolier) { + + } + @empty { +

{{t('no-data')}}

+ } + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.scss new file mode 100644 index 000000000..977ab17b5 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.scss @@ -0,0 +1,9 @@ + +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} + + diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts new file mode 100644 index 000000000..e71b1e62c --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts @@ -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 = new EventEmitter(); + + annotations: Signal = 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; + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html new file mode 100644 index 000000000..95cade611 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html @@ -0,0 +1,76 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + +
+ + + + + + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss new file mode 100644 index 000000000..f31bffc12 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss @@ -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; } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts new file mode 100644 index 000000000..d3fecf0cf --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts @@ -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(); + bookmarks = model(); + /** + * Current Page + */ + pageNum = model.required(); + loadPage: EventEmitter = new EventEmitter(); + /** + * Emitted when a bookmark is removed + */ + removeBookmark: EventEmitter = new EventEmitter(); + /** + * Used to refresh the Personal PoC + */ + refreshPToC: EventEmitter = new EventEmitter(); + loadPtoc: EventEmitter = new EventEmitter(); + + 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(); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html new file mode 100644 index 000000000..7fa69440c --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html @@ -0,0 +1,98 @@ + + +
+
+
+
+ + @if (mode() === AnnotationMode.Create) { + {{t('create-title')}} + } @else { + {{t(isEditMode() ? 'edit-title' : 'view-title')}} + } + +
+
+ +
+ @if (utilityService.activeUserBreakpoint() >= UserBreakpoint.Desktop) { + @if (isEditOrCreateMode() && this.annotation()) { +
+ +
+ } + } +
+ +
+ @if (isEditOrCreateMode() && formGroup) { +
+
+ + +
+
+ } @else if (annotation()?.containsSpoiler) { +
+ + {{t('contains-spoilers-label')}} +
+ } +
+
+ + @if (utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop) { + @if (isEditOrCreateMode() && this.annotation()) { +
+ +
+ } + } +
+ +
+ @if (annotation() && formGroup) { +
+
+
+
+

+
+ {{annotation()! | pageChapterLabel}} +
+
+ + + +
+ @if (isEditOrCreateMode()) { + + + } @else { + + } +
+ + @if (mode() === AnnotationMode.Create) { +
+ +
+ } + +
+ } + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss new file mode 100644 index 000000000..eb96a89e7 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss @@ -0,0 +1,48 @@ +@use 'quill-theme' as quill; + +@include quill.quill-white-theme; + +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} + +.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; +} + +.annotation-meta { + color: var(--drawer-text-color); +} + +.setting-section-break { + margin: 10px 0 !important; +} + +.offcanvas-title-right { + padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5); + margin: calc(-0.5 * var(--bs-offcanvas-padding-y)) calc(-0.5 * var(--bs-offcanvas-padding-x)) calc(-0.5 * var(--bs-offcanvas-padding-y)) auto; +} + +// Highlight stuff (needs to be moved to annotation service) + +$blue-color: rgba(59, 130, 246); +$green-color: rgba(34, 197, 94); + +.blue-title { + background-color: $blue-color; +} + +.green-title { + background-color: $green-color; +} + + diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts new file mode 100644 index 000000000..f29eaf532 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts @@ -0,0 +1,329 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + model, + OnInit, + Signal, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {AnnotationService} from "../../../../_services/annotation.service"; +import {FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from "@angular/forms"; +import {Annotation} from "../../../_models/annotations/annotation"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {debounceTime, switchMap} from "rxjs/operators"; +import {of} from "rxjs"; +import {HighlightBarComponent} from "../../_annotations/highlight-bar/highlight-bar.component"; +import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe"; +import {User} from "../../../../_models/user"; +import {DomSanitizer, SafeHtml} from "@angular/platform-browser"; +import {DOCUMENT, NgStyle} from "@angular/common"; +import {SafeHtmlPipe} from "../../../../_pipes/safe-html.pipe"; +import {EpubHighlightService} from "../../../../_services/epub-highlight.service"; +import {PageChapterLabelPipe} from "../../../../_pipes/page-chapter-label.pipe"; +import {UserBreakpoint, UtilityService} from "../../../../shared/_services/utility.service"; +import {QuillTheme, QuillWrapperComponent} from "../../quill-wrapper/quill-wrapper.component"; +import {ContentChange, QuillViewComponent} from "ngx-quill"; + +export enum AnnotationMode { + View = 0, + Edit = 1, + Create = 2, +} + +const INIT_HIGHLIGHT_DELAY = 200; + +@Component({ + selector: 'app-view-edit-annotation-drawer', + imports: [ + QuillWrapperComponent, + ReactiveFormsModule, + TranslocoDirective, + HighlightBarComponent, + NgStyle, + PageChapterLabelPipe, + QuillWrapperComponent, + QuillViewComponent + ], + templateUrl: './view-edit-annotation-drawer.component.html', + styleUrl: './view-edit-annotation-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ViewEditAnnotationDrawerComponent implements OnInit { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly annotationService = inject(AnnotationService); + private readonly destroyRef = inject(DestroyRef); + private readonly highlightSlotPipe = new SlotColorPipe(); + private readonly document = inject(DOCUMENT); + private readonly safeHtml = new SafeHtmlPipe(); + private readonly sanitizer = inject(DomSanitizer); + private readonly epubHighlightService = inject(EpubHighlightService); + private readonly fb = inject(NonNullableFormBuilder); + protected readonly utilityService = inject(UtilityService); + + @ViewChild('renderTarget', {read: ViewContainerRef}) renderTarget!: ViewContainerRef; + + annotation = model(null); + mode = model(AnnotationMode.View); + user = model(null); + isEditMode: Signal + isEditOrCreateMode: Signal + titleColor: Signal; + totalText!: Signal; + + + formGroup!: FormGroup<{ + note: FormControl, + hasSpoiler: FormControl, + selectedSlotIndex: FormControl, + }>; + annotationNote: object = {}; + + constructor() { + this.titleColor = computed(() => { + const annotation = this.annotation(); + const slots = this.annotationService.slots(); + + if (!annotation || annotation.selectedSlotIndex >= slots.length) return ''; + + return this.highlightSlotPipe.transform(slots[annotation.selectedSlotIndex].color); + }); + + this.isEditMode = computed(() => { + const mode = this.mode(); + return mode === AnnotationMode.Edit; + }); + + this.isEditOrCreateMode = computed(() => { + const mode = this.mode(); + return mode === AnnotationMode.Edit || mode === AnnotationMode.Create; + }); + + this.totalText = computed(() => { + const highlightAnnotation = this.annotation(); + const isCreateFlow = this.mode() === AnnotationMode.Create; + if (highlightAnnotation == null || highlightAnnotation?.context === null) return ''; + + const contextText = highlightAnnotation.context; + const selectedText = highlightAnnotation.selectedText!; + + const annotationId = isCreateFlow ? 0 : highlightAnnotation.id; + + if (!contextText.includes(selectedText)) { + return selectedText; + } + + // Get estimated character capacity for 2 lines + const estimatedCapacity = this.estimateCharacterCapacity('render-target') * 2; + + // If selected text alone is too long, just show it + if (selectedText.length >= estimatedCapacity) { + setTimeout(() => { + this.initHighlights(); + }, INIT_HIGHLIGHT_DELAY); + + return this.sanitizer.bypassSecurityTrustHtml(`${this.safeHtml.transform(selectedText)}`); + } + + // Find the position of selected text in context + const selectedIndex = contextText.indexOf(selectedText); + const selectedEndIndex = selectedIndex + selectedText.length; + + // Check if selected text follows punctuation (smart context detection) + const shouldIgnoreBeforeContext = this.isSelectedTextAfterPunctuation(contextText, selectedIndex); + + // Extract after text first to see if we have content after + const afterText = contextText.substring(selectedEndIndex); + + // If selected text follows punctuation AND we have after content, ignore before context + if (shouldIgnoreBeforeContext && afterText.trim().length > 0) { + const availableCapacity = estimatedCapacity - selectedText.length; + const trimmedAfterText = this.extractAfterContext(afterText, availableCapacity); + + setTimeout(() => { + this.initHighlights(); + }, INIT_HIGHLIGHT_DELAY); + + return this.sanitizer.bypassSecurityTrustHtml(`${this.safeHtml.transform(selectedText)}${this.safeHtml.transform(trimmedAfterText)}`); + } + + // Otherwise, use normal context distribution + const remainingCapacity = estimatedCapacity - selectedText.length; + const beforeCapacity = Math.floor(remainingCapacity * 0.4); // 40% before + const afterCapacity = remainingCapacity - beforeCapacity; // 60% after + + // Extract context portions + let beforeText = contextText.substring(0, selectedIndex); + let trimmedAfterText = afterText; + + // Trim context to fit capacity + if (beforeText.length > beforeCapacity) { + beforeText = '...' + beforeText.substring(beforeText.length - beforeCapacity + 3); + // Try to break at word boundary + const spaceIndex = beforeText.indexOf(' ', 3); + if (spaceIndex !== -1 && spaceIndex < beforeCapacity * 0.8) { + beforeText = '...' + beforeText.substring(spaceIndex + 1); + } + } + + if (trimmedAfterText.length > afterCapacity) { + trimmedAfterText = trimmedAfterText.substring(0, afterCapacity - 3) + '...'; + // Try to break at word boundary + const lastSpaceIndex = trimmedAfterText.lastIndexOf(' ', afterCapacity - 3); + if (lastSpaceIndex !== -1 && lastSpaceIndex > afterCapacity * 0.8) { + trimmedAfterText = trimmedAfterText.substring(0, lastSpaceIndex) + '...'; + } + } + + setTimeout(() => { + this.initHighlights(); + }, INIT_HIGHLIGHT_DELAY); + + return this.sanitizer.bypassSecurityTrustHtml(`${this.safeHtml.transform(beforeText)}${this.safeHtml.transform(selectedText)}${this.safeHtml.transform(trimmedAfterText)}`); + }); + + this.formGroup = this.fb.group({ + note: this.fb.control({}, []), + hasSpoiler: this.fb.control(false, []), + selectedSlotIndex: this.fb.control(0, []), + }); + } + + ngOnInit(){ + const annotation = this.annotation(); + if (annotation) { + this.annotationNote = annotation?.comment ? JSON.parse(annotation.comment) : {}; + this.formGroup.get('note')!.setValue(this.annotationNote); + this.formGroup.get('hasSpoiler')!.setValue(annotation.containsSpoiler); + this.formGroup.get('selectedSlotIndex')!.setValue(annotation.selectedSlotIndex); + } + + if (!this.isEditMode()) { + return; + } + + this.formGroup.valueChanges.pipe( + debounceTime(350), + switchMap(_ => { + const updatedAnnotation = this.annotation(); + if (!updatedAnnotation) return of(); + + updatedAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value; + updatedAnnotation.comment = JSON.stringify(this.annotationNote); + + return this.annotationService.updateAnnotation(updatedAnnotation); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + } + + createAnnotation() { + const highlightAnnotation = this.annotation(); + if (!highlightAnnotation) return; + + highlightAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value; + highlightAnnotation.comment = JSON.stringify(this.annotationNote); + // For create annotation, we have to have this hack + highlightAnnotation.createdUtc = '0001-01-01T00:00:00Z'; + highlightAnnotation.lastModifiedUtc = '0001-01-01T00:00:00Z' + + this.annotationService.createAnnotation(highlightAnnotation).subscribe(_ => { + this.close(); + }); + } + + changeSlotIndex(slotIndex: number) { + const annotation = this.annotation(); + + if (annotation) { + console.log('view-edit drawer, slot index changed: ', slotIndex, 'comment: ', this.annotation()?.comment, 'form comment: ', this.formGroup.get('note')?.value); + this.annotation.set({...annotation, selectedSlotIndex: slotIndex}); + this.formGroup.get('selectedSlotIndex')?.setValue(slotIndex); + + // Patch back in any text in the quill editor + console.log('(2) view-edit drawer, slot index changed: ', slotIndex, 'comment: ', this.annotation()?.comment, 'form comment: ', this.formGroup.get('note')?.value); + } + } + + close() { + this.activeOffcanvas.close(); + } + + updateContent(event: ContentChange) { + this.annotationNote = event.content; + } + + private initHighlights() { + const highlightAnnotation = this.annotation(); + if (highlightAnnotation === null) return; + + // Clear any existing components first + this.renderTarget.clear(); + + const parentElem = this.document.querySelector('#render-target'); + this.epubHighlightService.initializeHighlightElements([highlightAnnotation], this.renderTarget, parentElem, {showIcon: false, showHighlight: true}); + } + + private isSelectedTextAfterPunctuation(contextText: string, selectedIndex: number): boolean { + if (selectedIndex === 0) return false; + + // Look backwards from the selected text to find the last non-whitespace character + let checkIndex = selectedIndex - 1; + + // Skip whitespace + while (checkIndex >= 0 && /\s/.test(contextText[checkIndex])) { + checkIndex--; + } + + // If we found a character, check if it's punctuation + if (checkIndex >= 0) { + const lastChar = contextText[checkIndex]; + // Define sentence-ending punctuation + const sentenceEnders = ['.', '!', '?', '"', "'", ')', ']', '—', '–']; + return sentenceEnders.includes(lastChar); + } + + return false; + } + + private extractAfterContext(afterText: string, capacity: number): string { + if (afterText.length <= capacity) { + return afterText; + } + + let result = afterText.substring(0, capacity - 3) + '...'; + + // Try to break at word boundary + const lastSpaceIndex = result.lastIndexOf(' ', capacity - 3); + if (lastSpaceIndex !== -1 && lastSpaceIndex > capacity * 0.8) { + result = result.substring(0, lastSpaceIndex) + '...'; + } + + return result; + } + + private estimateCharacterCapacity(elementId: string): number { + const element = document.getElementById(elementId); + if (!element) return 100; // fallback + + const computedStyle = window.getComputedStyle(element); + const fontSize = parseFloat(computedStyle.fontSize); + const avgCharWidth = fontSize * 0.6; + + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); + const availableWidth = element.clientWidth - paddingLeft - paddingRight; + + return Math.floor(availableWidth / avgCharWidth); + } + + protected readonly AnnotationMode = AnnotationMode; + protected readonly UserBreakpoint = UserBreakpoint; + protected readonly QuillTheme = QuillTheme; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html new file mode 100644 index 000000000..f008665dd --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html @@ -0,0 +1,13 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts new file mode 100644 index 000000000..476cc585b --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + EventEmitter, + inject, + model +} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {TableOfContentsComponent} from "../../table-of-contents/table-of-contents.component"; +import {BookChapterItem} from "../../../_models/book-chapter-item"; +import {BookService} from "../../../_services/book.service"; +import {LoadPageEvent} from "../view-bookmarks-drawer/view-bookmark-drawer.component"; + + +@Component({ + selector: 'app-view-toc-drawer', + imports: [ + TranslocoDirective, + TableOfContentsComponent + ], + templateUrl: './view-toc-drawer.component.html', + styleUrl: './view-toc-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewTocDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly bookService = inject(BookService); + + chapterId = model(); + /** + * Current Page + */ + pageNum = model.required(); + + /** + * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors + */ + chapters = model>([]); + + + loadPage: EventEmitter = new EventEmitter(); + + constructor() { + + effect(() => { + const id = this.chapterId(); + if (!id) { + console.error('You must pass chapterId'); + return; + } + + this.bookService.getBookChapters(id).subscribe(bookChapters => { + this.chapters.set(bookChapters); + this.cdRef.markForCheck(); + }); + }); + } + + loadChapterPage(event: {pageNum: number, part: string}) { + const evt = {pageNumber: event.pageNum, part: `id("${event.part}")`} as LoadPageEvent; + this.loadPage.emit(evt); + } + + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html index f359c1767..14d7e7d04 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html @@ -1,46 +1,68 @@ -
+ @if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) { +
-
- - -
- -
-
- -
- -
- -
-
- -
-
- - -
-
- {{t('required-field')}} -
-
+
+ @switch (mode) { + @case (BookLineOverlayMode.None) { +
+
- - - + + @if (!hasSelectedAnnotation()) { +
+ +
+ } + +
+ +
+ +
+ +
+ } + + @case (BookLineOverlayMode.Annotate) { + + } + + @case (BookLineOverlayMode.Bookmark) { +
+
+ + + @if (bookmarkForm.dirty || bookmarkForm.touched) { +
+ @if (bookmarkForm.get('name')?.errors?.required) { +
+ {{t('required-field')}} +
+ } +
+ } + +
+
+ } + } +
+ +
- - -
+ } diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 000a7fad2..1c773f4bb 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -1,30 +1,37 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, DestroyRef, - ElementRef, EventEmitter, HostListener, + ElementRef, + EventEmitter, + HostListener, inject, Input, - OnInit, Output, + model, + OnInit, + Output, } from '@angular/core'; -import {CommonModule} from '@angular/common'; import {fromEvent, merge, of} from "rxjs"; -import {catchError} from "rxjs/operators"; +import {catchError, debounceTime, tap} from "rxjs/operators"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {ReaderService} from "../../../_services/reader.service"; import {ToastrService} from "ngx-toastr"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {KEY_CODES} from "../../../shared/_services/utility.service"; +import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; +import {Annotation} from "../../_models/annotations/annotation"; enum BookLineOverlayMode { None = 0, - Bookmark = 1 + Annotate = 1, + Bookmark = 2 } @Component({ selector: 'app-book-line-overlay', - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective], + imports: [ReactiveFormsModule, TranslocoDirective], templateUrl: './book-line-overlay.component.html', styleUrls: ['./book-line-overlay.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -39,19 +46,24 @@ export class BookLineOverlayComponent implements OnInit { @Output() refreshToC: EventEmitter = new EventEmitter(); @Output() isOpen: EventEmitter = new EventEmitter(false); - xPath: string = ''; + startXPath: string = ''; + endXPath: string = ''; + allTextFromSelection: string = ''; selectedText: string = ''; mode: BookLineOverlayMode = BookLineOverlayMode.None; bookmarkForm: FormGroup = new FormGroup({ name: new FormControl('', [Validators.required]), }); + hasSelectedAnnotation = model(false); + private readonly destroyRef = inject(DestroyRef); private readonly cdRef = inject(ChangeDetectorRef); private readonly readerService = inject(ReaderService); + private readonly toastr = inject(ToastrService); + private readonly elementRef = inject(ElementRef); + private readonly epubMenuService = inject(EpubReaderMenuService); - get BookLineOverlayMode() { return BookLineOverlayMode; } - constructor(private elementRef: ElementRef, private toastr: ToastrService) {} @HostListener('window:keydown', ['$event']) handleKeyPress(event: KeyboardEvent) { @@ -72,10 +84,11 @@ export class BookLineOverlayComponent implements OnInit { const touchEnd$ = fromEvent(this.parent.nativeElement, 'touchend'); merge(mouseUp$, touchEnd$) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((event: MouseEvent | TouchEvent) => { - this.handleEvent(event); - }); + .pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(20), // Need extra time for this extension to inject DOM https://github.com/Kareadita/Kavita/issues/3521 + tap((event: MouseEvent | TouchEvent) => this.handleEvent(event)) + ).subscribe(); } } @@ -84,8 +97,11 @@ export class BookLineOverlayComponent implements OnInit { if (!event.target) return; + // NOTE: This doesn't account for a partial occlusion with an annotation + this.hasSelectedAnnotation.set((event.target as HTMLElement).classList.contains('epub-highlight')); - if ((selection === null || selection === undefined || selection.toString().trim() === '' || selection.toString().trim() === this.selectedText)) { + if ((selection === null || selection === undefined || selection.toString().trim() === '' + || selection.toString().trim() === this.selectedText) || this.hasSelectedAnnotation()) { if (this.selectedText !== '') { event.preventDefault(); event.stopPropagation(); @@ -101,11 +117,26 @@ export class BookLineOverlayComponent implements OnInit { this.selectedText = selection ? selection.toString().trim() : ''; + if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) { - this.xPath = this.readerService.getXPathTo(event.target); - if (this.xPath !== '') { - this.xPath = '//' + this.xPath; - } + + // Get the range from the selection + const range = selection.getRangeAt(0); + + // Get start and end containers + const startContainer = this.getElementContainer(range.startContainer); + const endContainer = this.getElementContainer(range.endContainer); + + // Generate XPaths for both start and end + this.startXPath = this.readerService.getXPathTo(startContainer); + this.endXPath = this.readerService.getXPathTo(endContainer); + + // Protect from DOM Shift by removing the UI part and making this scoped to true epub html + this.startXPath = this.readerService.descopeBookReaderXpath(this.startXPath); + this.endXPath = this.readerService.descopeBookReaderXpath(this.endXPath); + + // Get the context window for generating a blurb in annotation flow + this.allTextFromSelection = (event.target as Element).textContent || ''; this.isOpen.emit(true); event.preventDefault(); @@ -120,12 +151,43 @@ export class BookLineOverlayComponent implements OnInit { if (this.mode === BookLineOverlayMode.Bookmark) { this.bookmarkForm.get('name')?.setValue(this.selectedText); this.focusOnBookmarkInput(); + return; + } + + if (this.mode === BookLineOverlayMode.Annotate) { + const createAnnotation = { + id: 0, + xPath: this.startXPath, + endingXPath: this.endXPath, + selectedText: this.selectedText, + comment: '', + containsSpoiler: false, + pageNumber: this.pageNumber, + selectedSlotIndex: 0, + chapterTitle: '', + highlightCount: this.selectedText.length, + ownerUserId: 0, + ownerUsername: '', + createdUtc: '', + lastModifiedUtc: '', + context: this.allTextFromSelection, + chapterId: this.chapterId, + libraryId: this.libraryId, + volumeId: this.volumeId, + seriesId: this.seriesId, + } as Annotation; + + this.epubMenuService.openCreateAnnotationDrawer(createAnnotation, () => { + this.reset(); + }); } } createPTOC() { + const xpath = this.readerService.descopeBookReaderXpath(this.startXPath); + this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, - this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => { + this.bookmarkForm.get('name')?.value, xpath, this.selectedText).pipe(catchError(err => { this.focusOnBookmarkInput(); return of(); })).subscribe(() => { @@ -143,8 +205,11 @@ export class BookLineOverlayComponent implements OnInit { reset() { this.bookmarkForm.reset(); this.mode = BookLineOverlayMode.None; - this.xPath = ''; + this.startXPath = ''; + this.endXPath = ''; + this.selectedText = ''; + this.allTextFromSelection = ''; const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); @@ -162,5 +227,12 @@ export class BookLineOverlayComponent implements OnInit { this.reset(); } + private getElementContainer(node: Node): Element { + // If the node is a text node, get its parent element + // If it's already an element, return it + return node.nodeType === Node.TEXT_NODE ? node.parentElement! : node as Element; + } + + protected readonly BookLineOverlayMode = BookLineOverlayMode; } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 69e4faf7b..b4259ca93 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -1,171 +1,164 @@ -
- +
{{t('skip-header')}} - - - - -
-
{{t('title')}}
- {{t('close-reader')}} -
-
-
- - @if (layoutMode !== BookPageLayoutMode.Default) { - @let vp = getVirtualPage(); -
-
- {{t('page-label')}} -
-
- -
{{vp[0]}}
-
- -
-
{{vp[1]}}
- -
-
- } -
- {{t('pagination-header')}} + + @if (page() !== undefined) { + + + } +
+ +
+ + @if (clickToPaginate() && !hidePagination()) { +
+
+ } + +
+ +
+ + @if ((scrollbarNeeded() || layoutMode() !== BookPageLayoutMode.Default) && !(writingStyle() === WritingStyle.Vertical && layoutMode() === BookPageLayoutMode.Default)) { +
+
-
- -
{{pageNum}}
-
- -
-
{{maxPages - 1}}
- + } +
+
+ + + @if (shouldShowMenu()) { +
+
+ + +
+ @if (isLoading()) { +
+ {{ t('loading-book') }} +
+ } @else { + @if (incognitoMode()) { + + ({{ t('incognito-mode-label') }}) + + } + + {{ bookTitle() }} + @if (utilityService.getActiveBreakpoint() >= Breakpoint.Desktop) { + - {{ authorText() }} + } + + + } +
+ +
+ @if (!this.adhocPageHistory.isEmpty()) { + + } + + + +
+ +
+ @if (isLoading()) { + + } @else { + + } +
-
- -
-
- -
- -
- - -
-
-
-
- -
-
- -
-
-
-
- - - -
- @if(isLoading) { -
- {{t('loading-book')}} -
- } @else { - - ({{t('incognito-mode-label')}}) - {{bookTitle}} + + @if (!immersiveMode() || epubMenuService.isDrawerOpen() || actionBarVisible()) { +
+ + + @if (!this.adhocPageHistory.isEmpty()) { + } + + +
+ @if(!isLoading()) { + + {{t('page-num-label', {page: virtualizedPageNum()})}} / {{virtualizedMaxPages()}} + + + {{t('completion-label', {percent: (virtualizedPageNum() / virtualizedMaxPages()) | percent})}} + @if (readingTimeLeftResource.value(); as timeLeft) { + , + + + {{timeLeft! | readTimeLeft:true }} + + } + } +
+ +
- - -
+ }
diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 8f45302c3..8084f5fef 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -52,6 +52,8 @@ font-display: swap; } + + :root { --br-actionbar-button-text-color: #6c757d; --accordion-body-bg-color: black; @@ -316,7 +318,7 @@ $action-bar-height: 38px; // A bunch of resets so books render correctly ::ng-deep .book-content { & a, & :link { - color: blue; + color: #8db2e5; } } @@ -356,6 +358,8 @@ $pagination-opacity: 0; //$pagination-opacity: 0.7; + + .right { position: absolute; right: 0px; @@ -367,6 +371,7 @@ $pagination-opacity: 0; border: none !important; opacity: 0; outline: none; + cursor: pointer; &.immersive { top: 0px; @@ -466,3 +471,26 @@ $pagination-opacity: 0; } } } + + + + +.reader-header { + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-body-bg); +} + +.progress-bar-container { + border-top: 1px solid var(--bs-border-color-translucent); + background-color: var(--br-actionbar-bg-color); // TODO: BUG: This isn't coloring well + max-height: 10px; + + .progress { + border-radius: 0; + background-color: var(--br-actionbar-bg-color); + } +} + +.page-progress-slider { + margin: 0; +} diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index ebfa82c7c..4dc4ff58f 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -3,34 +3,38 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, + effect, ElementRef, EventEmitter, HostListener, inject, - Inject, + model, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, - ViewChild + resource, + Signal, + ViewChild, + ViewContainerRef } from '@angular/core'; -import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common'; +import {DOCUMENT, NgClass, NgStyle, NgTemplateOutlet, PercentPipe} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; import {ToastrService} from 'ngx-toastr'; -import {forkJoin, fromEvent, merge, of} from 'rxjs'; -import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators'; +import {forkJoin, fromEvent, merge, of, switchMap} from 'rxjs'; +import {catchError, debounceTime, distinctUntilChanged, filter, take, tap} from 'rxjs/operators'; import {Chapter} from 'src/app/_models/chapter'; import {NavService} from 'src/app/_services/nav.service'; import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'src/app/_services/reader.service'; import {SeriesService} from 'src/app/_services/series.service'; -import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import {DomSanitizer, SafeHtml, Title} from '@angular/platform-browser'; import {BookService} from '../../_services/book.service'; -import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {BookChapterItem} from '../../_models/book-chapter-item'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {Stack} from 'src/app/shared/data-structures/stack'; -import {MemberService} from 'src/app/_services/member.service'; import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; import {WritingStyle} from "../../../_models/preferences/writing-style"; import {MangaFormat} from 'src/app/_models/manga-format'; @@ -38,38 +42,31 @@ import {LibraryService} from 'src/app/_services/library.service'; import {LibraryType} from 'src/app/_models/library/library'; import {BookTheme} from 'src/app/_models/preferences/book-theme'; import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; -import {PageStyle, ReaderSettingsComponent} from '../reader-settings/reader-settings.component'; +import {PageStyle} from '../reader-settings/reader-settings.component'; import {ThemeService} from 'src/app/_services/theme.service'; import {ScrollService} from 'src/app/_services/scroll.service'; import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {TableOfContentsComponent} from '../table-of-contents/table-of-contents.component'; -import { - NgbNav, - NgbNavContent, - NgbNavItem, - NgbNavItemRole, - NgbNavLink, - NgbNavOutlet, - NgbProgressbar, - NgbTooltip -} from '@ng-bootstrap/ng-bootstrap'; -import {DrawerComponent} from '../../../shared/drawer/drawer.component'; +import {NgbProgressbar, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component"; -import { - PersonalTableOfContentsComponent, - PersonalToCEvent -} from "../personal-table-of-contents/personal-table-of-contents.component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {ConfirmService} from "../../../shared/confirm.service"; - - -enum TabID { - Settings = 1, - TableOfContents = 2, - PersonalTableOfContents = 3 -} +import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; +import {EpubReaderSettingsService, ReaderSettingUpdate} from "../../../_services/epub-reader-settings.service"; +import {ColumnLayoutClassPipe} from "../../_pipes/column-layout-class.pipe"; +import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe"; +import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe"; +import {PageBookmark} from "../../../_models/readers/page-bookmark"; +import {EpubHighlightService} from "../../../_services/epub-highlight.service"; +import {AnnotationService} from "../../../_services/annotation.service"; +import {Annotation} from "../../_models/annotations/annotation"; +import {NgxSliderModule} from "@angular-slider/ngx-slider"; +import {ProgressBookmark} from "../../../_models/readers/progress-bookmark"; +import {LayoutMeasurementService} from "../../../_services/layout-measurement.service"; +import {ColorscapeService} from "../../../_services/colorscape.service"; +import {environment} from "../../../../environments/environment"; +import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component"; interface HistoryPoint { @@ -83,7 +80,7 @@ interface HistoryPoint { scrollPart: string; } -const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up // TODO: Do I need this or can I change it with new fixed top height +const TOP_OFFSET = -(50 + 10) * 1.5; // px the sticky header takes up // TODO: Do I need this or can I change it with new fixed top height const COLUMN_GAP = 20; // px /** @@ -95,6 +92,14 @@ const pageLevelStyles = ['margin-left', 'margin-right', 'font-size']; */ const elementLevelStyles = ['line-height', 'font-family']; +/** + * Minimum size to be assigned a bookmark + */ +const minImageSize = { + height: 200, + width: 100 +}; + @Component({ selector: 'app-book-reader', templateUrl: './book-reader.component.html', @@ -112,9 +117,9 @@ const elementLevelStyles = ['line-height', 'font-family']; transition('false <=> true', animate('4000ms')) ]) ], - imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, - NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, - BookLineOverlayComponent, PersonalTableOfContentsComponent, TranslocoDirective] + imports: [NgTemplateOutlet, NgStyle, NgClass, NgbTooltip, + BookLineOverlayComponent, TranslocoDirective, ColumnLayoutClassPipe, WritingStyleClassPipe, ReadTimeLeftPipe, PercentPipe, NgxSliderModule, NgbProgressbar], + providers: [EpubReaderSettingsService, LayoutMeasurementService], }) export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -122,22 +127,29 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly router = inject(Router); private readonly seriesService = inject(SeriesService); private readonly readerService = inject(ReaderService); + private readonly epubHighlightService = inject(EpubHighlightService); private readonly renderer = inject(Renderer2); private readonly navService = inject(NavService); private readonly toastr = inject(ToastrService); private readonly domSanitizer = inject(DomSanitizer); private readonly bookService = inject(BookService); - private readonly memberService = inject(MemberService); private readonly scrollService = inject(ScrollService); - private readonly utilityService = inject(UtilityService); + protected readonly utilityService = inject(UtilityService); private readonly libraryService = inject(LibraryService); private readonly themeService = inject(ThemeService); private readonly confirmService = inject(ConfirmService); private readonly cdRef = inject(ChangeDetectorRef); + protected readonly epubMenuService = inject(EpubReaderMenuService); + protected readonly readerSettingsService = inject(EpubReaderSettingsService); + private readonly destroyRef = inject(DestroyRef); + private readonly annotationService = inject(AnnotationService); + private readonly titleService = inject(Title); + private readonly document = inject(DOCUMENT); + private readonly layoutService = inject(LayoutMeasurementService); + private readonly colorscapeService = inject(ColorscapeService); protected readonly BookPageLayoutMode = BookPageLayoutMode; protected readonly WritingStyle = WritingStyle; - protected readonly TabID = TabID; protected readonly ReadingDirection = ReadingDirection; protected readonly PAGING_DIRECTION = PAGING_DIRECTION; @@ -156,25 +168,27 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * If this is true, no progress will be saved. */ - incognitoMode: boolean = false; + incognitoMode = model(false); /** - * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. + * If this is true, chapters will be fetched in the order of a reading list, + * rather than natural series order. */ readingListMode: boolean = false; /** - * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors + * The actual pages from the epub, used for showing on table of contents. + * This must be here as we need access to it for scroll anchors */ chapters: Array = []; /** * Current Page */ - pageNum = 0; + pageNum = model(0); /** * Max Pages */ - maxPages = 1; + maxPages = model(1); /** * This allows for exploration into different chapters */ @@ -189,38 +203,26 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * The current page only contains an image. This is used to determine if we should show the image in the center of the screen. */ isSingleImagePage = false; - /** - * Belongs to the drawer component - */ - activeTabId: TabID = TabID.Settings; - /** - * Sub Nav tab id - */ - tocId: TabID = TabID.TableOfContents; - /** - * Belongs to drawer component - */ - drawerOpen = false; /** * If the word/line overlay is open */ - isLineOverlayOpen = false; + isLineOverlayOpen = model(false); /** * If the action bar is visible */ - actionBarVisible = true; - /** - * Book reader setting that hides the menuing system - */ - immersiveMode: boolean = false; + actionBarVisible = model(true); /** * If we are loading from backend */ - isLoading = true; + isLoading = model(true); /** - * Title of the book. Rendered in action bars + * Title of the book. Rendered in action bar */ - bookTitle: string = ''; + bookTitle = model(''); + /** + * Authors of the book. Rendered in action bar + */ + authorText = model(''); /** * The boolean that decides if the clickToPaginate overlay is visible or not. */ @@ -231,7 +233,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * This is the html we get from the server */ - page: SafeHtml | undefined = undefined; + page = model(undefined); /** * Next Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking). */ @@ -265,11 +267,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ nextPageDisabled = false; - /** - * Internal property used to capture all the different css properties to render on all elements. This is a cached version that is updated from reader-settings component - */ - pageStyles!: PageStyle; - /** * Offset for drawer and rendering canvas. Fixed to 62px. */ @@ -279,20 +276,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Will hide if all content in book is absolute positioned */ horizontalScrollbarNeeded = false; - scrollbarNeeded = false; - readingDirection: ReadingDirection = ReadingDirection.LeftToRight; - clickToPaginate = false; + scrollbarNeeded = model(false); + /** * Used solely for fullscreen to apply a hack */ - darkMode = true; + darkMode = model(true); + readingTimeLeftResource = resource({ + params: () => ({ + chapterId: this.chapterId, + seriesId: this.seriesId, + pageNumber: this.pageNum(), + }), + loader: async ({params}) => { + return this.readerService.getTimeLeftForChapter(params.seriesId, params.chapterId).toPromise(); + } + }); + + imageBookmarks = model([]); + annotationToLoad = model(-1); + /** - * A anchors that map to the page number. When you click on one of these, we will load a given page up for the user. + * Anchors that map to the page number. When you click on one of these, we will load a given page up for the user. */ pageAnchors: {[n: string]: number } = {}; currentPageAnchor: string = ''; /** - * Last seen progress part path + * Last seen progress part path. This is not descoped. */ lastSeenScrollPartPath: string = ''; /** @@ -304,16 +314,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ isFullscreen: boolean = false; - /** - * How to render the page content - */ - layoutMode: BookPageLayoutMode = BookPageLayoutMode.Default; /** * Width of the document (in non-column layout), used for column layout virtual paging */ - windowWidth: number = 0; - windowHeight: number = 0; + windowWidth = model(0); + windowHeight = model(0); /** * used to track if a click is a drag or not, for opening menu @@ -328,20 +334,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; - writingStyle: WritingStyle = WritingStyle.Horizontal; - - /** * When the user is highlighting something, then we remove pagination */ - hidePagination = false; + hidePagination = model(false); /** * Used to refresh the Personal PoC */ refreshPToC: EventEmitter = new EventEmitter(); + /** + * Will be set to false once the initial page is injected, signalling that annotations can now process changes + */ + firstLoad: boolean = true; + - private readonly destroyRef = inject(DestroyRef); @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; /** @@ -351,169 +358,263 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; @ViewChild('reader', {static: false}) reader!: ElementRef; + @ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef; + + + protected readonly layoutMode = this.readerSettingsService.layoutMode; + protected readonly pageStyles = this.readerSettingsService.pageStyles; + protected readonly immersiveMode = this.readerSettingsService.immersiveMode; + protected readonly readingDirection = this.readerSettingsService.readingDirection; + protected readonly writingStyle = this.readerSettingsService.writingStyle; + protected readonly clickToPaginate = this.readerSettingsService.clickToPaginate; + + //protected columnWidth = this.readerSettingsService.columnWidth; + protected columnWidth!: Signal; + protected columnHeight!: Signal; + protected verticalBookContentWidth!: Signal; + protected virtualizedPageNum!: Signal; + protected virtualizedMaxPages!: Signal; + + pageWidthForPagination = computed(() => { + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + + if (layoutMode === BookPageLayoutMode.Default && writingStyle === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { + return 'unset'; + } + return '100%' + }); /** * Disables the Left most button */ - get IsPrevDisabled(): boolean { - if (this.readingDirection === ReadingDirection.LeftToRight) { + isPrevDisabled = computed(() => { + const readingDirection = this.readingDirection(); + + if (readingDirection === ReadingDirection.LeftToRight) { // Acting as Previous button return this.isPrevPageDisabled(); } // Acting as a Next button return this.isNextPageDisabled(); - } + }); - get IsNextDisabled(): boolean { - if (this.readingDirection === ReadingDirection.LeftToRight) { + isNextDisabled = computed(() => { + const readingDirection = this.readingDirection(); + + if (readingDirection === ReadingDirection.LeftToRight) { // Acting as Next button return this.isNextPageDisabled(); } // Acting as Previous button return this.isPrevPageDisabled(); - } + }); + + shouldShowMenu = computed(() => { + const immersiveMode = this.immersiveMode(); + const isDrawerOpen = this.epubMenuService.isDrawerOpen(); + const actionBarVisible = this.actionBarVisible(); + + return !immersiveMode || isDrawerOpen || actionBarVisible; + }) + isNextPageDisabled() { - const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); - const condition = (this.nextPageDisabled || this.nextChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum + 1 > this.maxPages - 1; - if (this.layoutMode !== BookPageLayoutMode.Default) { - return condition && currentVirtualPage === totalVirtualPages; - } - return condition; + const condition = (this.nextPageDisabled || this.nextChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum() + 1 > this.maxPages() - 1; + + if (this.layoutMode() !== BookPageLayoutMode.Default) { + const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); + return condition && currentVirtualPage === totalVirtualPages; + } + + return condition; } isPrevPageDisabled() { - const [currentVirtualPage,,] = this.getVirtualPage(); - const condition = (this.prevPageDisabled || this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum === 0; - if (this.layoutMode !== BookPageLayoutMode.Default) { - return condition && currentVirtualPage === 0; - } - return condition; + const condition = (this.prevPageDisabled || this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum() === 0; + + if (this.layoutMode() !== BookPageLayoutMode.Default) { + const [currentVirtualPage,, ] = this.getVirtualPage(); + return condition && currentVirtualPage === 1; + } + + return condition; } /** * Determines if we show >> or > */ get IsNextChapter(): boolean { - if (this.layoutMode === BookPageLayoutMode.Default) { - return this.pageNum + 1 >= this.maxPages; + if (this.layoutMode() === BookPageLayoutMode.Default) { + return this.pageNum() + 1 >= this.maxPages(); } const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); - if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum() + 1 >= this.maxPages(); - return this.pageNum + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages); + return this.pageNum() + 1 >= this.maxPages() && (currentVirtualPage === totalVirtualPages); } /** * Determines if we show << or < */ get IsPrevChapter(): boolean { - if (this.layoutMode === BookPageLayoutMode.Default) { - return this.pageNum === 0; + if (this.layoutMode() === BookPageLayoutMode.Default) { + return this.pageNum() === 0; } const [currentVirtualPage,,] = this.getVirtualPage(); - if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum() + 1 >= this.maxPages(); - return this.pageNum === 0 && (currentVirtualPage === 0); + return this.pageNum() === 0 && (currentVirtualPage === 0); } - get ColumnWidth() { - const base = this.writingStyle === WritingStyle.Vertical ? this.windowHeight : this.windowWidth; - switch (this.layoutMode) { - case BookPageLayoutMode.Default: - return 'unset'; - case BookPageLayoutMode.Column1: - return ((base / 2) - 4) + 'px'; - case BookPageLayoutMode.Column2: - return (base / 4) + 'px'; - default: - return 'unset'; - } - } - - get ColumnHeight() { - if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle === WritingStyle.Vertical) { - // Take the height after page loads, subtract the top/bottom bar - const height = this.windowHeight - (this.topOffset * 2); - return height + 'px'; - } - return 'unset'; - } - - get VerticalBookContentWidth() { - if (this.layoutMode !== BookPageLayoutMode.Default && this.writingStyle !== WritingStyle.Horizontal ) { - const width = this.getVerticalPageWidth() - return width + 'px'; - } - return ''; - } - - get ColumnLayout() { - switch (this.layoutMode) { - case BookPageLayoutMode.Default: - return ''; - case BookPageLayoutMode.Column1: - return 'column-layout-1'; - case BookPageLayoutMode.Column2: - return 'column-layout-2'; - } - } - - get WritingStyleClass() { - switch (this.writingStyle) { - case WritingStyle.Horizontal: - return ''; - case WritingStyle.Vertical: - return 'writing-style-vertical'; - } - } - - get PageWidthForPagination() { - if (this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { - return 'unset'; - } - return '100%' - } get PageHeightForPagination() { - if (this.layoutMode === BookPageLayoutMode.Default) { + const layoutMode = this.layoutMode(); + const immersiveMode = this.immersiveMode(); + const widthHeight = this.windowHeight(); + + if (layoutMode=== BookPageLayoutMode.Default) { // if the book content is less than the height of the container, override and return height of container for pagination area if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) { return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px'; } - return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (this.immersiveMode ? 0 : 1)) * 2) + 'px'; + return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px'; } - if (this.immersiveMode) return this.windowHeight + 'px'; - return (this.windowHeight) - (this.topOffset * 2) + 'px'; + if (immersiveMode) return widthHeight + 'px'; + return (widthHeight) - (this.topOffset * 2) + 'px'; } - constructor(@Inject(DOCUMENT) private document: Document) { + constructor() { this.navService.hideNavBar(); this.navService.hideSideNav(); this.themeService.clearThemes(); this.cdRef.markForCheck(); + + this.columnWidth = computed(() => { + switch (this.layoutMode()) { + case BookPageLayoutMode.Default: + return 'unset'; + case BookPageLayoutMode.Column1: + return ((this.pageWidth() / 2) - 4) + 'px'; + case BookPageLayoutMode.Column2: + return (this.pageWidth() / 4) + 'px' + default: + return 'unset'; + } + }); + + this.columnHeight = computed(() => { + // Note: Computed signals need to be called before if statement to ensure it's called when a dep signal is updated + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + const windowHeight = this.windowHeight(); + + + if (layoutMode !== BookPageLayoutMode.Default || writingStyle === WritingStyle.Vertical) { + // Take the height after page loads, subtract the top/bottom bar + const height = windowHeight - (this.topOffset * 2); + return height + 'px'; + } + return 'unset'; + }); + + this.verticalBookContentWidth = computed(() => { + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + const verticalPageWidth = this.getVerticalPageWidth(); + const pageStyles = this.pageStyles() ?? this.readerSettingsService.getDefaultPageStyles(); // Needed in inner method (not sure if Signals handle) + + + if (layoutMode !== BookPageLayoutMode.Default && writingStyle !== WritingStyle.Horizontal) { + return `${verticalPageWidth}px`; + } + return ''; + }); + + this.virtualizedPageNum = computed(() => { + return this.pageNum(); + }); + + this.virtualizedMaxPages = computed(() => { + return this.maxPages(); + }); + + effect(() => { + const annotationEvent = this.annotationService.events(); + const pageNum = this.pageNum(); + + if (annotationEvent == null || annotationEvent.pageNumber !== pageNum) return; + if (this.firstLoad) return; + + if (annotationEvent.type === 'edit') return; // Let signalR propagate state (or component can) + + this.firstLoad = true; + const scrollProgress = this.reader.nativeElement?.scrollTop || this.scrollService.scrollPosition; + + if (scrollProgress > 0) { + this.loadPage(undefined, scrollProgress); // This will force loading exactly on the scroll + } else { + this.loadPage(this.lastSeenScrollPartPath); + } + }); + + + // Prefetch next/prev chapter data based on page number + effect(() => { + const pageNum = this.pageNum(); + const maxPages = this.maxPages(); + + if (pageNum >= maxPages - 10) { + // Tell server to cache the next chapter + if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { + this.readerService.getChapterInfo(this.nextChapterId).pipe(catchError(err => { + this.nextChapterDisabled = true; + console.error(err); + return of(null); + })).subscribe(res => { + this.nextChapterPrefetched = true; + }); + } + } + + if (pageNum <= 10) { + if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { + this.readerService.getChapterInfo(this.prevChapterId).pipe(catchError(err => { + this.prevChapterDisabled = true; + console.error(err); + return of(null); + })).subscribe(res => { + this.prevChapterPrefetched = true; + }); + } + } + }); + } /** - * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the + * After the page has loaded, set up the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the * table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress * based on the last seen scroll part (xpath). */ ngAfterViewInit() { + + // Hook up the observers + this.setupObservers(); + + // check scroll offset and if offset is after any of the "id" markers, save progress fromEvent(this.reader.nativeElement, 'scroll') .pipe( debounceTime(200), + filter(_ => !this.isLoading()), + tap(_ => this.handleScrollEvent()), takeUntilDestroyed(this.destroyRef)) - .subscribe((event) => { - if (this.isLoading) return; - - this.handleScrollEvent(); - }); + .subscribe(); const mouseMove$ = fromEvent(this.bookContainerElemRef.nativeElement, 'mousemove'); const touchMove$ = fromEvent(this.bookContainerElemRef.nativeElement, 'touchmove'); @@ -524,7 +625,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { distinctUntilChanged(), tap((e) => { const selection = window.getSelection(); - this.hidePagination = selection !== null && selection.toString().trim() !== ''; + this.hidePagination.set(selection !== null && selection.toString().trim() !== ''); this.cdRef.markForCheck(); }) ) @@ -537,16 +638,28 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { .pipe( takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), - tap((e) => { - this.hidePagination = false; - this.cdRef.markForCheck(); - }) - ) - .subscribe(); - + tap(_ => this.hidePagination.set(false)) + ).subscribe(); } + private setupObservers() { + this.layoutService.observeElement( + this.bookContentElemRef.nativeElement, + 'bookContent' + ); + + this.layoutService.observeElement( + this.readingSectionElemRef.nativeElement, + 'readingSection' + ); + } + + /** + * Updates the TOC current page anchor, last scene path and saves progress + */ handleScrollEvent() { + + // TODO: See if we can move this to a service for ToC // Highlight the current chapter we are on if (Object.keys(this.pageAnchors).length !== 0) { // get the height of the document, so we can capture markers that are halfway on the document viewport @@ -564,7 +677,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Find the element that is on screen to bookmark against const xpath: string | null | undefined = this.getFirstVisibleElementXPath(); - if (xpath !== null && xpath !== undefined) this.lastSeenScrollPartPath = xpath; + if (xpath !== null && xpath !== undefined) { + this.lastSeenScrollPartPath = xpath; // Keep this scoped so we can appropriately handle before saving + } if (this.lastSeenScrollPartPath !== '') { this.saveProgress(); @@ -572,14 +687,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } saveProgress() { - let tempPageNum = this.pageNum; - if (this.pageNum == this.maxPages - 1) { - tempPageNum = this.pageNum + 1; + if (!this.incognitoMode()) { + let tempPageNum = this.pageNum(); + if (this.pageNum() == this.maxPages() - 1) { + tempPageNum = this.pageNum() + 1; + } + + const descopedPath = this.readerService.descopeBookReaderXpath(this.lastSeenScrollPartPath); + this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, descopedPath).subscribe(); } - if (!this.incognitoMode) { - this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); - } } ngOnDestroy(): void { @@ -598,7 +715,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.navService.showSideNav(); } - ngOnInit(): void { + async ngOnInit() { const libraryId = this.route.snapshot.paramMap.get('libraryId'); const seriesId = this.route.snapshot.paramMap.get('seriesId'); const chapterId = this.route.snapshot.paramMap.get('chapterId'); @@ -611,7 +728,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.libraryId = parseInt(libraryId, 10); this.seriesId = parseInt(seriesId, 10); this.chapterId = parseInt(chapterId, 10); - this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'; + this.incognitoMode.set(this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'); + + // If an annotation exists, load it and + if (this.route.snapshot.queryParamMap.has('annotation')) { + const annotationId = parseInt(this.route.snapshot.queryParamMap.get('annotation') ?? '0', 10); + this.annotationToLoad.set(annotationId); + + // Remove the annotation from the url + const queryParams = { ...this.route.snapshot.queryParams }; + delete queryParams['annotation']; + + // Navigate to same route with updated query params + await this.router.navigate([], { + relativeTo: this.route, + queryParams, + replaceUrl: true // This prevents adding to browser history + }); + } const readingListId = this.route.snapshot.queryParamMap.get('readingListId'); if (readingListId != null) { @@ -620,7 +754,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.cdRef.markForCheck(); - this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (data) => { this.readingProfile = data['readingProfile']; this.cdRef.markForCheck(); @@ -629,18 +763,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } - this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => { - if (!hasProgress) { - this.toggleDrawer(); - this.toastr.info(translate('toasts.book-settings-info')); - } - }); - - this.init(); + await this.init(); }); + + + const resize$ = fromEvent(window, 'resize'); + const orientationChange$ = fromEvent(window, 'orientationchange'); + + merge(resize$, orientationChange$) + .pipe( + debounceTime(200), + takeUntilDestroyed(this.destroyRef), + tap(_ => this.onResize()) + ) + .subscribe(); } - init() { + async init() { this.nextChapterId = CHAPTER_ID_NOT_FETCHED; this.prevChapterId = CHAPTER_ID_NOT_FETCHED; this.nextChapterDisabled = false; @@ -648,134 +787,191 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.nextChapterPrefetched = false; this.cdRef.markForCheck(); + this.loadImageBookmarks(); - this.bookService.getBookInfo(this.chapterId).subscribe(info => { + + this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => { if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { // Redirect to the manga reader. - const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); - this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); + const params = this.readerService.getQueryParamsObject(this.incognitoMode(), this.readingListMode, this.readingListId); + await this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); return; } - this.bookTitle = info.bookTitle; + this.bookTitle.set(info.bookTitle); + this.titleService.setTitle('Kavita - ' + this.bookTitle()); this.cdRef.markForCheck(); + await this.readerSettingsService.initialize(this.seriesId, this.readingProfile); + + // Ensure any changes in the reader settings are applied to the reader + this.readerSettingsService.settingUpdates$.pipe( + takeUntilDestroyed(this.destroyRef), + tap((update) => this.handleReaderSettingsUpdate(update)) + ).subscribe(); + forkJoin({ chapter: this.seriesService.getChapter(this.chapterId), progress: this.readerService.getProgress(this.chapterId), chapters: this.bookService.getBookChapters(this.chapterId), - }).subscribe(results => { - this.chapter = results.chapter; - this.volumeId = results.chapter.volumeId; - this.maxPages = results.chapter.pages; - this.chapters = results.chapters; - this.pageNum = results.progress.pageNum; - this.cdRef.markForCheck(); - - if (results.progress.bookScrollId) { - this.lastSeenScrollPartPath = results.progress.bookScrollId; + }).subscribe({ + next: ({chapter, progress, chapters}) => { + this.authorText.set(chapter.writers.map(p => p.name).join(', ')); + this.setupBookReader(chapter, progress, chapters); + }, + error: () => { + setTimeout(() => { + this.closeReader(); + }, 200); } - - this.continuousChaptersStack.push(this.chapterId); - - this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { - this.libraryType = type; - }); - - this.updateImageSizes(); - - if (this.pageNum >= this.maxPages) { - this.pageNum = this.maxPages - 1; - this.cdRef.markForCheck(); - this.saveProgress(); - } - - this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { - this.nextChapterId = chapterId; - if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { - this.nextChapterDisabled = true; - this.nextChapterPrefetched = true; - this.cdRef.markForCheck(); - return; - } - this.setPageNum(this.pageNum); - }); - this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { - this.prevChapterId = chapterId; - if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { - this.prevChapterDisabled = true; - this.prevChapterPrefetched = true; // If there is no prev chapter, then mark it as prefetched - this.cdRef.markForCheck(); - return; - } - this.setPageNum(this.pageNum); - }); - - // Check if user progress has part, if so load it so we scroll to it - this.loadPage(results.progress.bookScrollId || undefined); - this.readerService.enableWakeLock(this.reader.nativeElement); - }, () => { - setTimeout(() => { - this.closeReader(); - }, 200); }); }); } - @HostListener('window:resize', ['$event']) - @HostListener('window:orientationchange', ['$event']) + private setupBookReader(chapter: Chapter, progress: ProgressBookmark, chapters: BookChapterItem[]) { + this.chapter = chapter; + this.volumeId = chapter.volumeId; + this.chapters = chapters; + this.maxPages.set(chapter.pages); + //this.pageNum.set(progress.pageNum); + this.setPageNum(progress.pageNum); + this.cdRef.markForCheck(); + + if (progress.bookScrollId) { + // Don't descope here as document hasn't loaded + this.lastSeenScrollPartPath = progress.bookScrollId; + } + + this.continuousChaptersStack.push(this.chapterId); + + this.libraryService.getLibraryType(this.libraryId).subscribe(type => { + this.libraryType = type; + }); + + if (this.pageNum() >= this.maxPages()) { + this.pageNum.set(this.maxPages() - 1); + this.saveProgress(); + } + + this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => { + this.nextChapterId = chapterId; + if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { + this.nextChapterDisabled = true; + this.nextChapterPrefetched = true; + return; + } + }); + this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => { + this.prevChapterId = chapterId; + if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { + this.prevChapterDisabled = true; + this.prevChapterPrefetched = true; // If there is no prev chapter, then mark it as prefetched + return; + } + }); + + // If there is an annotation to load, prioritize it + if (this.annotationToLoad() > 0) { + this.annotationService.getAnnotation(this.annotationToLoad()).subscribe((data) => { + this.annotationToLoad.set(-1); + this.setPageNum(data.pageNumber); + this.loadPage(data.xPath || undefined); + this.readerService.enableWakeLock(this.reader.nativeElement); + }); + } else { + // Check if user progress has part, if so load it so we scroll to it + this.loadPage(progress.bookScrollId || undefined); + this.readerService.enableWakeLock(this.reader.nativeElement); + } + } + onResize(){ // Update the window Height this.updateWidthAndHeightCalcs(); this.updateImageSizes(); - const resumeElement = this.getFirstVisibleElementXPath(); - if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { - this.scrollTo(resumeElement); // This works pretty well, but not perfect + // Attempt to restore the reading position + this.snapScrollOnResize(); + // const resumeElement = this.getFirstVisibleElementXPath(); + // const layoutMode = this.layoutMode(); + // if (layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + // + // //const element = this.getElementFromXPath(resumeElement); + // //console.log('Resuming from resize to element: ', element); + // + // this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect + // } + } + + /** + * Only applies to non BookPageLayoutMode.Default and non-WritingStyle Horizontal + * @private + */ + private snapScrollOnResize() { + const layoutMode = this.layoutMode(); + if (layoutMode === BookPageLayoutMode.Default) return; + + // NOTE: Need to test on one of these books to validate + // || this.writingStyle() === WritingStyle.Horizontal + + + const resumeElement = this.getFirstVisibleElementXPath() ?? null; + if (resumeElement !== null) { + + const element = this.getElementFromXPath(resumeElement); + console.log('Attempting to snap to element: ', element); + + this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect } } @HostListener('window:keydown', ['$event']) async handleKeyPress(event: KeyboardEvent) { const activeElement = document.activeElement as HTMLElement; - const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA'; + const isInputFocused = activeElement.tagName === 'INPUT' + || activeElement.tagName === 'TEXTAREA' || + activeElement.contentEditable === 'true' || + activeElement.closest('.ql-editor'); // Quill editor class + if (isInputFocused) return; - if (event.key === KEY_CODES.RIGHT_ARROW) { - this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); - } else if (event.key === KEY_CODES.LEFT_ARROW) { - this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); - } else if (event.key === KEY_CODES.ESC_KEY) { - const isHighlighting = window.getSelection()?.toString() != ''; - if (isHighlighting) return; - this.closeReader(); - } else if (event.key === KEY_CODES.SPACE) { - this.toggleDrawer(); - event.stopPropagation(); - event.preventDefault(); - } else if (event.key === KEY_CODES.G) { - await this.goToPage(); - } else if (event.key === KEY_CODES.F) { - this.toggleFullscreen() + switch (event.key) { + case KEY_CODES.RIGHT_ARROW: + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); + break; + case KEY_CODES.LEFT_ARROW: + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); + break; + case KEY_CODES.ESC_KEY: + const isHighlighting = window.getSelection()?.toString() != ''; + if (isHighlighting || this.isLineOverlayOpen()) return; + + this.closeReader(); + break; + case KEY_CODES.G: + await this.goToPage(); + break; + case KEY_CODES.F: + this.applyFullscreen(); + break; } } onWheel(event: WheelEvent) { // This allows the user to scroll the page horizontally without holding shift - if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle !== WritingStyle.Vertical) { + if (this.layoutMode() !== BookPageLayoutMode.Default || this.writingStyle() !== WritingStyle.Vertical) { return; } if (event.deltaY !== 0) { - event.preventDefault() - this.scrollService.scrollToX( event.deltaY + this.reader.nativeElement.scrollLeft, this.reader.nativeElement); + event.preventDefault(); + this.scrollService.scrollToX(event.deltaY + this.reader.nativeElement.scrollLeft, this.reader.nativeElement); } } closeReader() { - this.readerService.closeReader(this.readingListMode, this.readingListId); + this.readerService.closeReader(this.libraryId, this.seriesId, this.chapterId, this.readingListMode, this.readingListId); } - sortElements(a: Element, b: Element) { const aTop = a.getBoundingClientRect().top; const bTop = b.getBoundingClientRect().top; @@ -789,23 +985,32 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return 0; } + loadImageBookmarks() { + this.readerService.getBookmarks(this.chapterId).subscribe(res => { + this.imageBookmarks.set(res); + this.injectImageBookmarkIndicators(true); + }); + } + loadNextChapter() { if (this.nextPageDisabled) { return; } - this.isLoading = true; + this.isLoading.set(true); + if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) { this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.nextChapterId = chapterId; this.loadChapter(chapterId, 'Next'); }); - } else { - this.loadChapter(this.nextChapterId, 'Next'); + return; } + + this.loadChapter(this.nextChapterId, 'Next'); } loadPrevChapter() { if (this.prevPageDisabled) { return; } - this.isLoading = true; + this.isLoading.set(true); this.cdRef.markForCheck(); this.continuousChaptersStack.pop(); const prevChapter = this.continuousChaptersStack.peek(); @@ -818,7 +1023,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (this.prevChapterPrefetched && this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) { - this.isLoading = false; + this.isLoading.set(false); this.cdRef.markForCheck(); return; } @@ -837,39 +1042,29 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (chapterId >= 0) { this.chapterId = chapterId; this.continuousChaptersStack.push(chapterId); + // Ensure all scroll locks are undone + this.scrollService.unlock(); + console.log('cleared lock: ', this.scrollService.isScrollingLock()) // Load chapter Id onto route but don't reload - const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); + const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode(), this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()}); this.toastr.info(msg, '', {timeOut: 3000}); this.cdRef.markForCheck(); this.init(); - } else { - // This will only happen if no actual chapter can be found - const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()}); - this.toastr.warning(msg); - this.isLoading = false; - if (direction === 'Prev') { - this.prevPageDisabled = true; - } else { - this.nextPageDisabled = true; - } - this.cdRef.markForCheck(); + return; } - } - loadChapterPage(event: {pageNum: number, part: string}) { - this.setPageNum(event.pageNum); - this.loadPage('id("' + event.part + '")'); - } - - /** - * From personal table of contents/bookmark - * @param event - */ - loadChapterPart(event: PersonalToCEvent) { - this.setPageNum(event.pageNum); - this.loadPage(event.scrollPart); + // This will only happen if no actual chapter can be found + const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()}); + this.toastr.warning(msg); + this.isLoading.set(false); + if (direction === 'Prev') { + this.prevPageDisabled = true; + } else { + this.nextPageDisabled = true; + } + this.cdRef.markForCheck(); } /** @@ -888,12 +1083,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (!targetElem.attributes.hasOwnProperty('kavita-page')) { return; } const page = parseInt(targetElem.attributes['kavita-page'].value, 10); - if (this.adhocPageHistory.peek()?.page !== this.pageNum) { - this.adhocPageHistory.push({page: this.pageNum, scrollPart: this.lastSeenScrollPartPath}); + if (this.adhocPageHistory.peek()?.page !== this.pageNum()) { + this.adhocPageHistory.push({page: this.pageNum(), scrollPart: this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath)}); } const partValue = targetElem.attributes.hasOwnProperty('kavita-part') ? targetElem.attributes['kavita-part'].value : undefined; - if (partValue && page === this.pageNum) { + if (partValue && page === this.pageNum()) { this.scrollTo(targetElem.attributes['kavita-part'].value); return; } @@ -911,12 +1106,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - async promptForPage() { const promptConfig = {...this.confirmService.defaultPrompt}; - // Pages are called sections in the UI, manga reader uses the go-to-page string so we use a different one here - promptConfig.header = translate('book-reader.go-to-section'); - promptConfig.content = translate('book-reader.go-to-section-prompt', {totalSections: this.maxPages - 1}); + promptConfig.header = translate('book-reader.go-to-page'); + promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages() - 1}); const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); @@ -933,35 +1126,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { page = parseInt(goToPageNum.trim(), 10); } - if (page === undefined || this.pageNum === page) { return; } + if (page === undefined || this.pageNum() === page) { return; } - if (page > this.maxPages - 1) { - page = this.maxPages - 1; + if (page > this.maxPages() - 1) { + page = this.maxPages() - 1; } else if (page < 0) { page = 0; } - this.pageNum = page; + this.pageNum.set(page); this.loadPage(); } - - - loadPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = true; + this.isLoading.set(true); this.cdRef.markForCheck(); - this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { + this.bookService.getBookPage(this.chapterId, this.pageNum()).subscribe(content => { this.isSingleImagePage = this.checkSingleImagePage(content) // This needs be performed before we set this.page to avoid image jumping this.updateSingleImagePageStyles(); - this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage + this.page.set(this.domSanitizer.bypassSecurityTrustHtml(content)); + this.scrollService.unlock(); this.cdRef.markForCheck(); setTimeout(() => { this.addLinkClickHandlers(); - this.updateReaderStyles(this.pageStyles); + this.applyPageStyles(this.pageStyles()); const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); if (imgs === null || imgs.length === 0) { @@ -975,20 +1166,115 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { .then(() => { this.setupPage(part, scrollTop); this.updateImageSizes(); + this.injectImageBookmarkIndicators(); + this.setupObservers(); }); + + this.firstLoad = false; }, 10); }); } + /** + * Injects the new DOM needed to provide the bookmark functionality. + * We can't use a wrapper due to potential for styling issues. + */ + injectImageBookmarkIndicators(forceRefresh = false) { + const imgs = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('img') ?? []); + + const bookmarksForPage = (this.imageBookmarks() ?? []).filter(b => b.page === this.pageNum()); + + if (forceRefresh) { + // Remove all existing bookmark overlays + const existingOverlays = this.readingSectionElemRef.nativeElement.querySelectorAll('.bookmark-overlay'); + existingOverlays.forEach(overlay => overlay.remove()); + } + + imgs.forEach((img, index) => { + if (img.nextElementSibling?.classList.contains('bookmark-overlay')) return; + + const xpath = this.readerService.descopeBookReaderXpath(this.readerService.getXPathTo(img)); + const matchingBookmarks = bookmarksForPage.filter(b => b.imageOffset === index); + let hasBookmark = matchingBookmarks.length > 0; + + const container = img.parentNode; + if (container == null) return; + + const imgRect = img.getBoundingClientRect(); + if (imgRect.height < minImageSize.height || imgRect.width < minImageSize.width) { + return; + } + + const parentRect = (container as HTMLElement).getBoundingClientRect(); + + const relativeX = imgRect.left - parentRect.left; + const relativeY = imgRect.top - parentRect.top; + + const icon = document.createElement('div'); + icon.className = 'bookmark-overlay ' + (hasBookmark ? 'fa-solid' : 'fa-regular') + ' fa-bookmark'; + icon.title = hasBookmark + ? translate('manga-reader.unbookmark-page-tooltip') + : translate('manga-reader.bookmark-page-tooltip'); + + const avgColour = this.colorscapeService.getAverageColour(img); + let backgroundColor; + let textColor; + + if (!avgColour || this.colorscapeService.getLuminance(avgColour) > ColorscapeService.defaultLuminanceThreshold) { + backgroundColor = 'rgba(0, 0, 0, 0.8)'; + textColor = 'white'; + } else { + backgroundColor = 'rgba(255, 255, 255, 1)'; + textColor = 'black'; + } + + icon.style.cssText = ` + position: absolute; + left: ${relativeX + imgRect.width - 16 * 2}px; + top: ${relativeY + imgRect.height - 16 * 2}px; + margin: 0; + transform-origin: bottom right; + padding-top: 5px; + padding-bottom: 5px; + z-index: 1000; + cursor: pointer; + border-radius: 2px; + background: ${backgroundColor} !important; + color: ${textColor} !important; + `; + + + (container as HTMLElement).style.position = 'relative'; + container.appendChild(icon); + + fromEvent(icon, 'click') + .pipe( + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + debounceTime(200), + switchMap(() => hasBookmark + ? this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index) + : this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index, xpath)), + tap(() => { + hasBookmark = !hasBookmark; + icon.className = 'bookmark-overlay ' + (hasBookmark ? 'fa-solid' : 'fa-regular') + ' fa-bookmark'; + this.loadImageBookmarks(); + }), + ) + .subscribe(); + }); + + } + /** * Updates the image properties to fit the current layout mode and screen size */ updateImageSizes() { - const isVerticalWritingStyle = this.writingStyle === WritingStyle.Vertical; - const height = this.windowHeight - (this.topOffset * 2); + const isVerticalWritingStyle = this.writingStyle() === WritingStyle.Vertical; + const height = this.windowHeight() - (this.topOffset * 2); let maxHeight = 'unset'; let maxWidth = ''; - switch (this.layoutMode) { + switch (this.layoutMode()) { case BookPageLayoutMode.Default: if (isVerticalWritingStyle) { maxHeight = `${height}px`; @@ -1017,16 +1303,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateSingleImagePageStyles() { - if (this.isSingleImagePage && this.layoutMode !== BookPageLayoutMode.Default) { + if (this.isSingleImagePage && this.layoutMode() !== BookPageLayoutMode.Default) { this.document.documentElement.style.setProperty('--book-reader-content-position', 'absolute'); this.document.documentElement.style.setProperty('--book-reader-content-top', '50%'); this.document.documentElement.style.setProperty('--book-reader-content-left', '50%'); this.document.documentElement.style.setProperty('--book-reader-content-transform', 'translate(-50%, -50%)'); } else { - this.document.documentElement.style.setProperty('--book-reader-content-position', ''); - this.document.documentElement.style.setProperty('--book-reader-content-top', ''); - this.document.documentElement.style.setProperty('--book-reader-content-left', ''); - this.document.documentElement.style.setProperty('--book-reader-content-transform', ''); + this.document.documentElement.style.setProperty('--book-reader-content-position', ''); + this.document.documentElement.style.setProperty('--book-reader-content-top', ''); + this.document.documentElement.style.setProperty('--book-reader-content-left', ''); + this.document.documentElement.style.setProperty('--book-reader-content-transform', ''); } } @@ -1042,61 +1328,91 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return false; } - const images = doc.querySelectorAll('img, svg'); - return images.length === 1; + const images = doc.querySelectorAll('img, svg, image'); + return images.length === 1; } - setupPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = false; + this.isLoading.set(false); this.cdRef.markForCheck(); // Virtual Paging stuff this.updateWidthAndHeightCalcs(); - this.updateLayoutMode(this.layoutMode); + this.applyLayoutMode(this.layoutMode()); this.addEmptyPageIfRequired(); // Find all the part ids and their top offset this.setupPageAnchors(); - if (part !== undefined && part !== '') { - this.scrollTo(part); - } else if (scrollTop !== undefined && scrollTop !== 0) { - setTimeout(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); - } else if ((this.writingStyle === WritingStyle.Vertical) && (this.layoutMode === BookPageLayoutMode.Default)) { - setTimeout(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); - } else { - - if (this.layoutMode === BookPageLayoutMode.Default) { - setTimeout(() => this.scrollService.scrollTo(0, this.reader.nativeElement)); - } else if (this.writingStyle === WritingStyle.Vertical) { - if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { - setTimeout(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto')); - } else { - setTimeout(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement,'auto' )); - } - } - else { - // We need to check if we are paging back, because we need to adjust the scroll - if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { - setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); - } else { - setTimeout(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); - } - } + try { + this.setupPageScroll(part, scrollTop); + } catch (ex) { + console.error(ex); } // we need to click the document before arrow keys will scroll down. this.reader.nativeElement.focus(); this.saveProgress(); - this.isLoading = false; + this.isLoading.set(false); + this.cdRef.markForCheck(); + + this.annotationService.getAllAnnotations(this.chapterId).subscribe(_ => { + this.setupAnnotationElements(); + }); + } + + private setupPageScroll(part?: string | undefined, scrollTop?: number) { + if (part !== undefined && part !== '') { + this.scrollTo(this.readerService.scopeBookReaderXpath(part)); + return; + } + + if (scrollTop !== undefined && scrollTop !== 0) { + setTimeout(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); + return; + } + + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + + if (layoutMode === BookPageLayoutMode.Default) { + if (writingStyle === WritingStyle.Vertical) { + setTimeout(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); + return; + } + + setTimeout(() => this.scrollService.scrollTo(0, this.reader.nativeElement)); + return; + } + + if (writingStyle === WritingStyle.Vertical) { + if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + setTimeout(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto')); + return; + } + + setTimeout(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement,'auto' )); + return; + } + + // We need to check if we are paging back, because we need to adjust the scroll + if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); + return; + } + + setTimeout(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); + } + + private setupAnnotationElements() { + this.epubHighlightService.initializeHighlightElements(this.annotationService.annotations(), this.readingContainer); this.cdRef.markForCheck(); } private addEmptyPageIfRequired(): void { - if (this.layoutMode !== BookPageLayoutMode.Column2 || this.isSingleImagePage) { + if (this.layoutMode() !== BookPageLayoutMode.Column2 || this.isSingleImagePage) { return; } @@ -1110,15 +1426,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // Need to adjust height with the column gap to ensure we don't have too much extra page - const columnHeight = this.getPageHeight() - COLUMN_GAP; + const columnHeight = this.pageHeight() - COLUMN_GAP; const emptyPage = this.renderer.createElement('div'); this.renderer.setStyle(emptyPage, 'height', columnHeight + 'px'); - this.renderer.setStyle(emptyPage, 'width', this.ColumnWidth); + this.renderer.setStyle(emptyPage, 'width', this.columnWidth()); this.renderer.appendChild(this.bookContentElemRef.nativeElement, emptyPage); } - goBack() { if (!this.adhocPageHistory.isEmpty()) { const page = this.adhocPageHistory.pop(); @@ -1130,31 +1445,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setPageNum(pageNum: number) { - this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); - this.cdRef.markForCheck(); - - if (this.pageNum >= this.maxPages - 10) { - // Tell server to cache the next chapter - if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { - this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1), catchError(err => { - this.nextChapterDisabled = true; - this.cdRef.markForCheck(); - return of(null); - })).subscribe(res => { - this.nextChapterPrefetched = true; - }); - } - } else if (this.pageNum <= 10) { - if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { - this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1), catchError(err => { - this.prevChapterDisabled = true; - this.cdRef.markForCheck(); - return of(null); - })).subscribe(res => { - this.prevChapterPrefetched = true; - }); - } - } + this.pageNum.set(Math.max(Math.min(pageNum, this.maxPages()), 0)); } /** @@ -1162,36 +1453,52 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * @param direction Direction to move */ movePage(direction: PAGING_DIRECTION) { - if (direction === PAGING_DIRECTION.BACKWARDS) { - this.prevPage(); - return; + switch (direction) { + case PAGING_DIRECTION.BACKWARDS: + this.prevPage(); + break; + case PAGING_DIRECTION.FORWARD: + this.nextPage(); + break; } - - this.nextPage(); } prevPage() { - const oldPageNum = this.pageNum; + const oldPageNum = this.pageNum(); this.pagingDirection = PAGING_DIRECTION.BACKWARDS; + const isColumnLayout = this.layoutMode() !== BookPageLayoutMode.Default; // We need to handle virtual paging before we increment the actual page - if (this.layoutMode !== BookPageLayoutMode.Default) { - const [currentVirtualPage, _, pageWidth] = this.getVirtualPage(); + if (isColumnLayout) { + const [currentVirtualPage, _, pageSize] = this.getVirtualPage(); if (currentVirtualPage > 1) { + // Calculate the target scroll position for the previous page + const targetScroll = (currentVirtualPage - 2) * pageSize + const isVertical = this.writingStyle() === WritingStyle.Vertical; + // -2 apparently goes back 1 virtual page... - if (this.writingStyle === WritingStyle.Vertical) { - this.scrollService.scrollTo((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement, 'auto'); - } else { - this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement); - } - this.handleScrollEvent(); + const scrollMethod = isVertical ? 'scrollTo' : 'scrollToX'; + this.scrollService[scrollMethod]( + targetScroll, + this.bookContentElemRef.nativeElement, + 'auto', + () => { + this.handleScrollEvent(); + }, + { + tolerance: 3, + timeout: 2000 + } + ); return; } } - this.setPageNum(this.pageNum - 1); + + const newPageNum = this.pageNum() - 1; + this.setPageNum(newPageNum); if (oldPageNum === 0) { // Move to next volume/chapter automatically @@ -1199,7 +1506,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } - if (oldPageNum === this.pageNum) { return; } + if (oldPageNum === newPageNum) { return; } + this.loadPage(); } @@ -1210,56 +1518,78 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.pagingDirection = PAGING_DIRECTION.FORWARD; + + // We need to handle virtual paging before we increment the actual page - if (this.layoutMode !== BookPageLayoutMode.Default) { - const [currentVirtualPage, totalVirtualPages, pageWidth] = this.getVirtualPage(); + if (this.layoutMode() !== BookPageLayoutMode.Default) { + const [currentVirtualPage, totalVirtualPages, pageSize] = this.getVirtualPage(); if (currentVirtualPage < totalVirtualPages) { + + // Calculate the target scroll position for the next page + const targetScroll = currentVirtualPage * pageSize; + const isVertical = this.writingStyle() === WritingStyle.Vertical; + // +0 apparently goes forward 1 virtual page... - if (this.writingStyle === WritingStyle.Vertical) { - this.scrollService.scrollTo( (currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement, 'auto'); - } else { - this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement); - } - this.handleScrollEvent(); + const scrollMethod = isVertical ? 'scrollTo' : 'scrollToX'; + this.scrollService[scrollMethod]( + targetScroll, + this.bookContentElemRef.nativeElement, + 'auto', + () => { + this.handleScrollEvent(); + }, + { + tolerance: 3, + timeout: 2000 + } + ); return; } } - const oldPageNum = this.pageNum; - if (oldPageNum + 1 === this.maxPages) { + const oldPageNum = this.pageNum(); + if (oldPageNum + 1 === this.maxPages()) { // Move to next volume/chapter automatically this.loadNextChapter(); return; } - this.setPageNum(this.pageNum + 1); - - if (oldPageNum === this.pageNum) { return; } + this.setPageNum(this.pageNum() + 1); + if (oldPageNum === this.pageNum()) { return; } this.loadPage(); } + /** - * + * This is the total space for the book content, excluding margin and the column gap (aka how big each column is) * @returns Total Page width (excluding margin) */ - getPageWidth() { + pageWidth = computed(() => { + const marginLeft = this.pageStyles()['margin-left']; + const columnGapModifier = this.layoutMode() === BookPageLayoutMode.Default ? 0 : 1; if (this.readingSectionElemRef == null) return 0; - const margin = (this.convertVwToPx(parseInt(this.pageStyles['margin-left'], 10)) * 2); - return this.readingSectionElemRef.nativeElement.clientWidth - margin + COLUMN_GAP; - } - getPageHeight() { + const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); + return this.readingSectionElemRef.nativeElement.clientWidth - margin + (COLUMN_GAP * columnGapModifier); + }); + + pageHeight = computed(() => { + const columnHeight = this.columnHeight(); if (this.readingSectionElemRef == null) return 0; - const height = (parseInt(this.ColumnHeight.replace('px', ''), 10)); + + const height = (parseInt(columnHeight.replace('px', ''), 10)); return height - COLUMN_GAP; - } + }); + getVerticalPageWidth() { - const margin = (window.innerWidth * (parseInt(this.pageStyles['margin-left'], 10) / 100)) * 2; + if (!(this.pageStyles() || {}).hasOwnProperty('margin-left')) return 0; // TODO: Test this, added for safety during refactor + + const margin = (window.innerWidth * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2; const windowWidth = window.innerWidth || document.documentElement.clientWidth; return windowWidth - margin; } @@ -1277,9 +1607,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (!this.bookContentElemRef || !this.readingSectionElemRef) return [1, 1, 0]; const [scrollOffset, totalScroll] = this.getScrollOffsetAndTotalScroll(); - const pageSize = this.getPageSize(); + const pageSize = this.pageSize(); + + if (pageSize <= 0 || totalScroll <= 0) return [1, 1, pageSize]; + const totalVirtualPages = Math.max(1, Math.ceil(totalScroll / pageSize)); - const delta = scrollOffset - totalScroll; + const delta = totalScroll - scrollOffset; let currentVirtualPage = 1; //If first virtual page, i.e. totalScroll and delta are the same value @@ -1298,25 +1631,25 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private getScrollOffsetAndTotalScroll() { const { nativeElement: bookContent } = this.bookContentElemRef; - const scrollOffset = this.writingStyle === WritingStyle.Vertical + const scrollOffset = this.writingStyle() === WritingStyle.Vertical ? bookContent.scrollTop : bookContent.scrollLeft; - const totalScroll = this.writingStyle === WritingStyle.Vertical + const totalScroll = this.writingStyle() === WritingStyle.Vertical ? bookContent.scrollHeight : bookContent.scrollWidth; return [scrollOffset, totalScroll]; } - private getPageSize() { - return this.writingStyle === WritingStyle.Vertical - ? this.getPageHeight() - : this.getPageWidth(); - } + pageSize = computed(() => { + return this.writingStyle() === WritingStyle.Vertical + ? this.pageHeight() + : this.pageWidth(); + }); getFirstVisibleElementXPath() { let resumeElement: string | null = null; - if (this.bookContentElemRef === null) return null; + if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null; const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) .filter(element => !element.classList.contains('no-observe')) @@ -1324,24 +1657,81 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return this.utilityService.isInViewport(entry, this.topOffset); }); - intersectingEntries.sort(this.sortElements); + intersectingEntries.sort((a, b) => this.sortElementsForLayout(a, b)); if (intersectingEntries.length > 0) { let path = this.readerService.getXPathTo(intersectingEntries[0]); - if (path === '') { return; } - if (!path.startsWith('id')) { - path = '//html[1]/' + path; - } + if (path === '') return; + resumeElement = path; } return resumeElement; } /** - * Applies styles onto the html of the book page + * Sort elements based on layout mode for better scroll position tracking */ - updateReaderStyles(pageStyles: PageStyle) { - this.pageStyles = pageStyles; + private sortElementsForLayout(a: Element, b: Element): number { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + + switch (this.layoutMode()) { + case BookPageLayoutMode.Default: + return this.sortElements(a, b); + case BookPageLayoutMode.Column1: + return this.sortForSingleColumnLayout(a, b, aRect, bRect); + case BookPageLayoutMode.Column2: + return this.sortForTwoColumnLayout(a, b, aRect, bRect); + } + } + + /** + * Sort for 2-column layout: prefer elements closer to the left (smaller scrollTop equivalent) + */ + private sortForTwoColumnLayout(a: Element, b: Element, aRect: DOMRect, bRect: DOMRect): number { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Convert horizontal position to a "reading order" score + // Elements on the left column should be preferred over right column + // Within the same column, prefer elements higher up + + // Determine which column each element is in + const aColumn = aRect.left < viewportWidth / 2 ? 0 : 1; // 0 = left, 1 = right + const bColumn = bRect.left < viewportWidth / 2 ? 0 : 1; + + // If elements are in different columns, prefer left column + if (aColumn !== bColumn) { + return aColumn - bColumn; + } + + // If in the same column, prefer elements higher up (smaller top value) + if (Math.abs(aRect.top - bRect.top) > 10) { // 10px tolerance for "same row" + return aRect.top - bRect.top; + } + + // If roughly at the same vertical level, prefer left-most + return aRect.left - bRect.left; + } + + /** + * Sort for single column layout: prefer elements higher up + */ + private sortForSingleColumnLayout(a: Element, b: Element, aRect: DOMRect, bRect: DOMRect): number { + // Primary sort: vertical position (top to bottom) + if (Math.abs(aRect.top - bRect.top) > 5) { // 5px tolerance + return aRect.top - bRect.top; + } + + // Secondary sort: horizontal position (left to right) + return aRect.left - bRect.left; + } + + /** + * Applies styles onto the html of the book page. + * Note: This has a critical role when margin changes and 2 column layout is in play + */ + applyPageStyles(pageStyles: PageStyle) { if (this.bookContentElemRef === undefined || !this.bookContentElemRef.nativeElement) return; // Before we apply styles, let's get an element on the screen so we can scroll to it after any shifts @@ -1353,7 +1743,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Line Height must be placed on each element in the page // Apply page level overrides - Object.entries(this.pageStyles).forEach(item => { + Object.entries(pageStyles).forEach(item => { if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { // Remove the style or skip this.renderer.removeStyle(this.bookContentElemRef.nativeElement, item[0]); @@ -1364,24 +1754,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); + const individualElementStyles = Object.entries(pageStyles).filter(item => elementLevelStyles.includes(item[0])); for(let i = 0; i < this.bookContentElemRef.nativeElement.children.length; i++) { const elem = this.bookContentElemRef.nativeElement.children.item(i); if (elem?.tagName === 'STYLE') continue; + individualElementStyles.forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(elem, item[0]); - return; - } - this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); - }); + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(elem, item[0]); + return; + } + this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); + }); } // After layout shifts, we need to refocus the scroll bar - if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + // NOTE: THis is called almost always and not just from layout shift + if (this.layoutMode() !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { this.updateWidthAndHeightCalcs(); - this.scrollTo(resumeElement); // This works pretty well, but not perfect + this.updateImageSizes(); // Re-call this as we will change window width/height again + + requestAnimationFrame(() => { + this.scrollTo(resumeElement); + }); } } @@ -1389,11 +1785,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Applies styles and classes that control theme * @param theme */ - updateColorTheme(theme: BookTheme) { + applyColorTheme(theme: BookTheme) { // Remove all themes Array.from(this.document.querySelectorAll('style[id^="brtheme-"]')).forEach(elem => elem.remove()); - this.darkMode = theme.isDarkTheme; + this.darkMode.set(theme.isDarkTheme); + this.cdRef.markForCheck(); const styleElem = this.renderer.createElement('style'); styleElem.id = theme.selector; @@ -1406,73 +1803,118 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateWidthAndHeightCalcs() { - this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight); - this.windowWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth); + this.windowHeight.set(Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight)); + this.windowWidth.set(Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth)); // Recalculate if bottom action bar is needed - this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; + this.scrollbarNeeded.set(this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight); this.horizontalScrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientWidth > this.reader?.nativeElement?.clientWidth; this.cdRef.markForCheck(); } - toggleDrawer() { - this.drawerOpen = !this.drawerOpen; + handleReaderSettingsUpdate(res: ReaderSettingUpdate) { + switch (res.setting) { + case "pageStyle": + this.applyPageStyles(res.object as PageStyle); + break; + case "clickToPaginate": + this.showPaginationOverlay(res.object as boolean); + break; + case "fullscreen": + this.applyFullscreen(); + break; + case "writingStyle": + this.applyWritingStyle(); + break; + case "layoutMode": + this.applyLayoutMode(res.object as BookPageLayoutMode); + break; + case "readingDirection": + // No extra functionality needs to be done + break; + case "immersiveMode": + this.applyImmersiveMode(res.object as boolean); + break; + case 'theme': + this.applyColorTheme(res.object as BookTheme); + return; + } + } - if (this.immersiveMode) { - this.actionBarVisible = false; + toggleDrawer() { + const drawerIsOpen = this.epubMenuService.isDrawerOpen(); + if (drawerIsOpen) { + this.epubMenuService.closeAll(); + } else { + this.epubMenuService.openSettingsDrawer(this.chapterId, this.seriesId, this.readingProfile, this.readerSettingsService); + } + + if (this.immersiveMode()) { // NOTE: Shouldn't this check if drawer is open? + this.actionBarVisible.set(false); } this.cdRef.markForCheck(); } - scrollTo(partSelector: string) { + scrollTo(partSelector: string, timeout: number = 0) { + const element = this.getElementFromXPath(partSelector); + + if (element === null) { + if (!environment.production) { + console.warn("Tried to scroll to a non existing XPath", partSelector); + } + + return; + } + + const layout = this.layoutMode(); + const writingStyle = this.writingStyle(); + + if (layout !== BookPageLayoutMode.Default) { + setTimeout(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}})); + return; + } + + switch (writingStyle) { + case WritingStyle.Vertical: + const windowWidth = window.innerWidth || document.documentElement.clientWidth; + const scrollLeft = element.getBoundingClientRect().left + window.scrollX - (windowWidth - element.getBoundingClientRect().width); + setTimeout(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'), 10); + break; + case WritingStyle.Horizontal: + const fromTopOffset = element.getBoundingClientRect().top + window.scrollY + TOP_OFFSET; + // We need to use a delay as webkit browsers (aka Apple devices) don't always have the document rendered by this point + setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); + } + } + + getElementFromXPath(partSelector: string) { if (partSelector.startsWith('#')) { - partSelector = partSelector.substr(1, partSelector.length); + partSelector = partSelector.substring(1, partSelector.length); } let element: Element | null = null; if (partSelector.startsWith('//') || partSelector.startsWith('id(')) { // Part selector is a XPATH - element = this.getElementFromXPath(partSelector); + element = this.readerService.getElementFromXPath(partSelector); } else { element = this.document.querySelector('*[id="' + partSelector + '"]'); } - if (element === null) return; - - if(this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical ) { - const windowWidth = window.innerWidth || document.documentElement.clientWidth; - const scrollLeft = element.getBoundingClientRect().left + window.pageXOffset - (windowWidth - element.getBoundingClientRect().width); - setTimeout(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'), 10); - } - else if ((this.layoutMode === BookPageLayoutMode.Default) && (this.writingStyle === WritingStyle.Horizontal)) { - const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; - // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point - setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); - } else { - setTimeout(() => (element as Element).scrollIntoView({'block': 'start', 'inline': 'start'})); - } - } - - getElementFromXPath(path: string) { - const node = this.document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - if (node?.nodeType === Node.ELEMENT_NODE) { - return node as Element; - } - return null; + return element ?? null; } /** * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state */ turnOffIncognito() { - this.incognitoMode = false; - const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); + this.incognitoMode.set(false); + const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode(), this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); - this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); + this.toastr.info(translate('toasts.incognito-off')); this.saveProgress(); } - toggleFullscreen() { + applyFullscreen() { this.isFullscreen = this.readerService.checkFullscreenMode(); if (this.isFullscreen) { this.readerService.toggleFullscreen(this.reader.nativeElement, () => { @@ -1486,18 +1928,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); // HACK: This is a bug with how browsers change the background color for fullscreen mode this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); - if (!this.darkMode) { + if (!this.darkMode()) { this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); } }); } } - updateWritingStyle(writingStyle: WritingStyle) { - this.writingStyle = writingStyle; + applyWritingStyle() { setTimeout(() => this.updateImageSizes()); - if (this.layoutMode !== BookPageLayoutMode.Default) { - const lastSelector = this.lastSeenScrollPartPath; + if (this.layoutMode() !== BookPageLayoutMode.Default) { + const lastSelector = this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath); setTimeout(() => { this.scrollTo(lastSelector); }); @@ -1512,10 +1953,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); } - updateLayoutMode(mode: BookPageLayoutMode) { - const layoutModeChanged = mode !== this.layoutMode; - this.layoutMode = mode; - this.cdRef.markForCheck(); + applyLayoutMode(mode: BookPageLayoutMode) { + //const layoutModeChanged = mode !== this.layoutMode(); // TODO: This functionality wont work on the new signal-based logic this.clearTimeout(this.updateImageSizeTimeout); this.updateImageSizeTimeout = setTimeout( () => { @@ -1525,34 +1964,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateSingleImagePageStyles() // Calculate if bottom actionbar is needed. On a timeout to get accurate heights - if (this.bookContentElemRef == null) { - setTimeout(() => this.updateLayoutMode(this.layoutMode), 10); - return; - } + // if (this.bookContentElemRef == null) { + // setTimeout(() => this.applyLayoutMode(this.layoutMode()), 10); + // return; + // } setTimeout(() => { - this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; + // TODO: Why is this logic duplicated? + this.scrollbarNeeded.set(this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight); this.horizontalScrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientWidth > this.reader?.nativeElement?.clientWidth; this.cdRef.markForCheck(); }); // When I switch layout, I might need to resume the progress point. - if (mode === BookPageLayoutMode.Default && layoutModeChanged) { - const lastSelector = this.lastSeenScrollPartPath; - setTimeout(() => this.scrollTo(lastSelector)); - } + // if (mode === BookPageLayoutMode.Default && layoutModeChanged) { + // const lastSelector = this.lastSeenScrollPartPath; + // setTimeout(() => this.scrollTo(lastSelector)); + // } } - updateReadingDirection(readingDirection: ReadingDirection) { - this.readingDirection = readingDirection; - this.cdRef.markForCheck(); - } - - updateImmersiveMode(immersiveMode: boolean) { - this.immersiveMode = immersiveMode; - if (this.immersiveMode && !this.drawerOpen) { - this.actionBarVisible = false; + applyImmersiveMode(immersiveMode: boolean) { + if (immersiveMode && !this.epubMenuService.isDrawerOpen()) { + this.actionBarVisible.set(false); + this.updateReadingSectionHeight(); } - this.updateReadingSectionHeight(); + this.cdRef.markForCheck(); } @@ -1561,9 +1996,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const elem = this.readingSectionElemRef; setTimeout(() => { if (renderer === undefined || elem === undefined) return; - if (this.immersiveMode) { - } else { - renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); + if (!this.immersiveMode()) { + renderer.setStyle(elem.nativeElement, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); } }); } @@ -1590,7 +2024,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageAnchors = {}; this.currentPageAnchor = ''; this.cdRef.markForCheck(); - const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0); + const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum()).map(item => item.part).filter(item => item.length > 0); if (ids.length > 0) { const elems = this.getPageMarkers(ids); elems.forEach(elem => { @@ -1601,7 +2035,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Settings Handlers showPaginationOverlay(clickToPaginate: boolean) { - this.clickToPaginate = clickToPaginate; + this.readerSettingsService.updateClickToPaginate(clickToPaginate); this.cdRef.markForCheck(); this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); @@ -1645,14 +2079,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return ''; } - if (this.readingDirection === ReadingDirection.LeftToRight) { + if (this.readingDirection() === ReadingDirection.LeftToRight) { return side === 'right' ? 'highlight' : 'highlight-2'; } return side === 'right' ? 'highlight-2' : 'highlight'; } handleReaderClick(event: MouseEvent) { - if (!this.clickToPaginate) { + if (!this.clickToPaginate()) { event.preventDefault(); event.stopPropagation(); this.toggleMenu(event); @@ -1671,7 +2105,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const targetElement = (event.target as Element); const mouseOffset = 5; - if (!this.immersiveMode) return; + if (!this.immersiveMode()) return; if (targetElement.getAttribute('onclick') !== null || targetElement.getAttribute('href') !== null || targetElement.getAttribute('role') !== null || targetElement.getAttribute('kavita-part') != null) { // Don't do anything, it's actionable return; @@ -1681,14 +2115,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { Math.abs(this.mousePosition.x - event.clientX) <= mouseOffset && Math.abs(this.mousePosition.y - event.clientY) <= mouseOffset ) { - this.actionBarVisible = !this.actionBarVisible; + this.actionBarVisible.update(v => !v); this.cdRef.markForCheck(); } } mouseDown($event: MouseEvent) { - this.mousePosition.x = $event.clientX; - this.mousePosition.y = $event.clientY; + this.mousePosition = {x: $event.clientX, y: $event.clientY}; } refreshPersonalToC() { @@ -1696,7 +2129,74 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateLineOverlayOpen(isOpen: boolean) { - this.isLineOverlayOpen = isOpen; - this.cdRef.markForCheck(); + this.isLineOverlayOpen.set(isOpen); } + + + viewBookmarkImages() { + this.epubMenuService.openViewBookmarksDrawer(this.chapterId, this.pageNum(), + (res: PageBookmark | null, action) => { + if (res === null) return; + + if (action === 'loadPage') { + this.setPageNum(res.page); + if (res.xPath != null) { + this.loadPage(res.xPath); + } + return; + } else if (action === 'removeBookmark') { + this.loadImageBookmarks(); + } + }, (res: LoadPageEvent) => { + if (res === null) return; + + this.setPageNum(res.pageNumber); + this.loadPage(res.part); + }); + } + + viewAnnotations() { + this.epubMenuService.openViewAnnotationsDrawer((annotation: Annotation) => { + if (this.pageNum() != annotation.pageNumber) { + this.setPageNum(annotation.pageNumber); + } + + if (annotation.xPath != null) { + this.loadPage(annotation.xPath); + } + }); + } + + viewToCDrawer() { + this.epubMenuService.openViewTocDrawer(this.chapterId, this.pageNum(), (res: LoadPageEvent | null) => { + if (res === null) return; + + this.setPageNum(res.pageNumber); + this.loadPage(res.part); + }); + } + + private debugVirtualPaging() { + if (this.layoutMode() === BookPageLayoutMode.Default) return; + + const [scrollOffset, totalScroll] = this.getScrollOffsetAndTotalScroll(); + const pageSize = this.pageSize(); + const [currentVirtualPage, totalVirtualPages] = this.getVirtualPage(); + + console.log('Virtual Paging Debug:', { + scrollOffset, + totalScroll, + pageSize, + currentVirtualPage, + totalVirtualPages, + layoutMode: this.layoutMode(), + writingStyle: this.writingStyle(), + bookContentWidth: this.bookContentElemRef?.nativeElement?.clientWidth, + bookContentHeight: this.bookContentElemRef?.nativeElement?.clientHeight, + scrollWidth: this.bookContentElemRef?.nativeElement?.scrollWidth, + scrollHeight: this.bookContentElemRef?.nativeElement?.scrollHeight + }); + } + + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html index 591972499..b48479a5c 100644 --- a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html @@ -1,32 +1,36 @@ - +
- @if (Pages.length === 0) { + @let bookmarks = ptocBookmarks(); + + @if(bookmarks.length >= ShowFilterAfterItems) { +
+
+
+ +
+ +
+
+
+
+ } + + @for(bookmark of bookmarks | filter: filterList; track bookmark.pageNumber + bookmark.title) { +
+ +
+ } @empty {
- {{t('no-data')}} + @if (formGroup.get('filter')?.value) { + {{t('no-match')}} + } @else { + {{t('no-data')}} + } +
} -
    - @for (page of Pages; track page) { -
  • - {{t('page', {value: page})}} -
      - @for(bookmark of bookmarks[page]; track bookmark) { -
    • - {{bookmark.title}} - -
    • - } -
    -
  • - } - -
diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts index d45dfb60e..207059b13 100644 --- a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts @@ -1,19 +1,22 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, - Component, DestroyRef, EventEmitter, - Inject, + Component, + DestroyRef, + EventEmitter, inject, Input, + model, OnInit, Output } from '@angular/core'; -import {DOCUMENT} from '@angular/common'; import {ReaderService} from "../../../_services/reader.service"; import {PersonalToC} from "../../../_models/readers/personal-toc"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {TextBookmarkItemComponent} from "../text-bookmark-item/text-bookmark-item.component"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {FilterPipe} from "../../../_pipes/filter.pipe"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; export interface PersonalToCEvent { pageNum: number; @@ -21,31 +24,30 @@ export interface PersonalToCEvent { } @Component({ - selector: 'app-personal-table-of-contents', - imports: [NgbTooltip, TranslocoDirective], - templateUrl: './personal-table-of-contents.component.html', - styleUrls: ['./personal-table-of-contents.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-personal-table-of-contents', + imports: [TranslocoDirective, TextBookmarkItemComponent, FilterPipe, FormsModule, ReactiveFormsModule], + templateUrl: './personal-table-of-contents.component.html', + styleUrls: ['./personal-table-of-contents.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonalTableOfContentsComponent implements OnInit { + private readonly readerService = inject(ReaderService); + private readonly destroyRef = inject(DestroyRef); + private readonly confirmService = inject(ConfirmService); + + protected readonly ShowFilterAfterItems = 10; + @Input({required: true}) chapterId!: number; @Input({required: true}) pageNum: number = 0; @Input({required: true}) tocRefresh!: EventEmitter; @Output() loadChapter: EventEmitter = new EventEmitter(); - private readonly readerService = inject(ReaderService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly destroyRef = inject(DestroyRef); - - bookmarks: {[key: number]: Array} = []; - - get Pages() { - return Object.keys(this.bookmarks).map(p => parseInt(p, 10)); - } - - constructor(@Inject(DOCUMENT) private document: Document) {} + ptocBookmarks = model([]); + formGroup = new FormGroup({ + filter: new FormControl('', []) + }); ngOnInit() { this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { @@ -57,13 +59,7 @@ export class PersonalTableOfContentsComponent implements OnInit { load() { this.readerService.getPersonalToC(this.chapterId).subscribe(res => { - res.forEach(t => { - if (!this.bookmarks.hasOwnProperty(t.pageNumber)) { - this.bookmarks[t.pageNumber] = []; - } - this.bookmarks[t.pageNumber].push(t); - }) - this.cdRef.markForCheck(); + this.ptocBookmarks.set(res); }); } @@ -71,15 +67,18 @@ export class PersonalTableOfContentsComponent implements OnInit { this.loadChapter.emit({pageNum, scrollPart}); } - removeBookmark(bookmark: PersonalToC) { - this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => { - this.bookmarks[bookmark.pageNumber] = this.bookmarks[bookmark.pageNumber].filter(t => t.title != bookmark.title); + async removeBookmark(bookmark: PersonalToC) { - if (this.bookmarks[bookmark.pageNumber].length === 0) { - delete this.bookmarks[bookmark.pageNumber]; - } - this.cdRef.markForCheck(); + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-bookmark'))) return; + + this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => { + this.ptocBookmarks.set(this.ptocBookmarks().filter(t => t.title !== bookmark.title)); }); } + filterList = (listItem: PersonalToC) => { + const query = (this.formGroup.get('filter')?.value || '').toLowerCase(); + return listItem.title.toLowerCase().indexOf(query) >= 0 || listItem.pageNumber.toString().indexOf(query) >= 0 || (listItem.chapterTitle ?? '').toLowerCase().indexOf(query) >= 0; + } + } diff --git a/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.html b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.html new file mode 100644 index 000000000..cb4752461 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.html @@ -0,0 +1,36 @@ +
+ + +
+ + @for (toolbarGroup of toolbar(); track $index) { + +
+ @for (toolbarItem of toolbarGroup; track $index) { + + + @if (toolbarItem.value !== undefined) { + + } @else if (toolbarItem.values !== undefined) { + + } @else { + + } + + + } +
+ + } +
+ +
+
diff --git a/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.scss b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.scss new file mode 100644 index 000000000..72357dad6 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.scss @@ -0,0 +1,170 @@ +:host ::ng-deep quill-editor { + .ql-toolbar, + .ql-container { + border: 0.0625rem solid var(--primary-color); + background-color: var(--drawer-bg-color); + } + + .ql-toolbar { + border-bottom: 0.0625rem solid var(--primary-color); + padding: 0.5rem; + border-radius: 0.3125rem 0.3125rem 0 0 !important; + + .ql-formats { + margin-right: 0.9375rem; + } + } + + .ql-container { + border-top: none; + border-radius: 0 0 0.3125rem 0.3125rem !important; + + .ql-editor { + background-color: var(--theme-bg-color); + color: var(--body-text-color); + min-height: 60px; + + &::before { + color: var(--input-placeholder-color); + } + + &.ql-blank::before { + font-style: italic; + color: var(--input-placeholder-color); + } + + &:focus { + outline: none; + } + } + } + + .quil-wrapper-btn, + button[class*="ql-"] { + color: var(--drawer-text-color) !important; + background-color: transparent; + border: 0.0625rem solid transparent; + border-radius: 0.1875rem; + padding: 0.3125rem; + margin: 0.125rem; + cursor: pointer; + + &:hover { + background-color: var(--btn-primary-hover-bg-color); + color: var(--btn-primary-hover-text-color) !important; + } + + svg { + transform: scale(1.5); + } + + &.ql-active { + background-color: var(--btn-primary-bg-color); + color: var(--drawer-text-color) !important; + + .ql-stroke { + stroke: var(--drawer-text-color) !important; + } + + .ql-fill { + fill: var(--drawer-text-color) !important; + } + } + } + + .ql-stroke { + stroke: var(--drawer-text-color) !important; + } + + .ql-fill { + fill: var(--drawer-text-color) !important; + } + + .quil-wrapper-select, + .ql-picker { + color: var(--drawer-text-color) !important; + background-color: var(--drawer-bg-color) !important; + margin: 0.125rem; + min-width: 1.875rem; + + .ql-picker-label { + color: var(--drawer-text-color) !important; + border: 0.0625rem solid var(--input-border-color); + border-radius: 0.1875rem; + background-color: var(--input-bg-color) !important; + text-align: center !important; + display: flex !important; + align-items: flex-start !important; + justify-content: flex-start !important; + + &:hover { + .ql-stroke { + stroke: var(--btn-primary-hover-text-color) !important; + } + } + + svg { + display: none !important; + } + } + + .ql-picker-options { + background-color: var(--drawer-bg-color); + border: 0.0625rem solid var(--input-border-color); + border-radius: 0.1875rem; + box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1); + min-width: auto !important; + width: 100% !important; + + .ql-picker-item { + color: var(--drawer-text-color) !important; + border-radius: 0.3125rem; + padding-left: 0.125rem; + + &:hover { + background-color: var(--btn-primary-hover-bg-color); + color: var(--btn-primary-hover-text-color) !important; + } + + &.ql-selected { + background-color: var(--btn-primary-bg-color); + color: var(--drawer-text-color) !important; + } + } + } + + &.ql-expanded { + .ql-picker-label { + border-color: var(--primary-color); + background-color: var(--btn-primary-bg-color); + color: var(--drawer-text-color) !important; + + .ql-stroke { + stroke: var(--drawer-text-color) !important; + } + + svg { + display: none !important; + } + } + } + } + + select.quil-wrapper-select { + background-color: var(--input-bg-color); + color: var(--input-text-color); + border: 0.0625rem solid var(--input-border-color); + border-radius: 0.1875rem; + + option { + background-color: var(--drawer-bg-color); + color: var(--drawer-text-color); + } + } + + .ql-formats:not(:last-child) { + margin-right: 0.9375rem; + padding-right: 0.9375rem; + border-right: 0.0625rem solid var(--input-border-color); + } +} diff --git a/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.ts b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.ts new file mode 100644 index 000000000..7894b069f --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/quill-wrapper/quill-wrapper.component.ts @@ -0,0 +1,156 @@ +import {ChangeDetectionStrategy, Component, computed, EventEmitter, input, OnInit, Output} from '@angular/core'; +import {ContentChange, QuillEditorComponent, QuillFormat} from "ngx-quill"; +import {FormGroup, ReactiveFormsModule} from "@angular/forms"; + +export enum QuillTheme { + Snow = 'snow', + Bubble = 'bubble', +} + +/** + * Keys for the different options to display in the toolbar + */ +export enum QuillToolbarKey { + Bold = 'ql-bold', + Italic = 'ql-italic', + Underline = 'ql-underline', + Strikethrough = 'ql-strike', + Blockquote = 'ql-blockquote', + CodeBlock = 'ql-code-block', + Header = 'ql-header', + List = 'ql-list', + Script = 'ql-script', + Indent = 'ql-indent', + Direction = 'ql-direction', + FontSize = 'ql-size', + Color = 'ql-color', + BackgroundColor = 'ql-background', + Font = 'ql-font', + Alignment = 'ql-align', + EmbedLink = 'ql-link', + EmbedImage = 'ql-image', + EmbedVideo = 'ql-video', + Table = 'ql-table', + Clean = 'ql-clean', +} + +export interface ToolbarItem { + /** + * This key is not always unique + */ + key: QuillToolbarKey; + /** + * Value passed to the button itself + */ + value?: string; + /** + * Values used for the select component + * Pass an **empty** array to use the quill defaults + */ + values?: string[]; +} + +// There is very little documentation to what values are possible. +// https://quilljs.com/docs/modules/toolbar + inspect the editor on that page to figure it out +const defaultToolbarItems: ToolbarItem[][] = [ + [ + { + key: QuillToolbarKey.FontSize, + values: [], + }, + { + key: QuillToolbarKey.Font, + values: [], + }, + ], + [ + {key: QuillToolbarKey.Bold}, + {key: QuillToolbarKey.Italic}, + {key: QuillToolbarKey.Underline}, + {key: QuillToolbarKey.Strikethrough}, + {key: QuillToolbarKey.List, value: 'bullet'}, + {key: QuillToolbarKey.List, value: 'ordered'}, + ], + [ + {key: QuillToolbarKey.EmbedLink}, + {key: QuillToolbarKey.EmbedImage}, + ], + + [ + {key: QuillToolbarKey.Clean}, + ] +]; + +/** + * This component is a wrapper around the quill editor for a nicer to use API, and styling that integrates into the + * Kavita style + */ +@Component({ + selector: 'app-quill-wrapper', + imports: [ + QuillEditorComponent, + ReactiveFormsModule, + ], + templateUrl: './quill-wrapper.component.html', + styleUrl: './quill-wrapper.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class QuillWrapperComponent { + + /** + * The data format used to pass through quill. + * @default Object + */ + format = input('object'); + + /** + * The quill theme to use + * @default Snow + */ + theme = input(QuillTheme.Snow); + + formGroup = input.required(); + controlName = input.required(); + + /** + * Deligation of the quill onContentChange event + */ + @Output() contentChanged = new EventEmitter(); + + /** + * Items to show in the toolbar + * @default defaultToolbarItems + */ + toolBarItems = input(defaultToolbarItems); + + /** + * If not an empty list, only items with their keys present will be shown + */ + whiteList = input([]); + /** + * Keys in this list will not be shown, unless in the whiteList + */ + blackList = input([]); + + + toolbar = computed(() => { + const items = this.toolBarItems(); + const whiteList = this.whiteList(); + const blackList = this.blackList(); + + if (whiteList.length === 0 && blackList.length === 0) { + return items; + } + + if (whiteList.length > 0) { + return items + .map(group => group.filter(item => whiteList.includes(item.key))) + .filter(group => group.length > 0); + } + + return items + .map(group => group.filter(item => !blackList.includes(item.key))) + .filter(group => group.length > 0); + }); + +} diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index a4bc4cdfa..7e1f5b682 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -1,6 +1,5 @@ @if (readingProfile !== null) { - - +
@@ -17,15 +16,19 @@
- - + +
@@ -34,7 +37,9 @@ {{t('line-spacing-min-label')}} - + {{t('line-spacing-max-label')}}
@@ -59,7 +64,7 @@
-
+

{{t('writing-style-tooltip')}} -
@@ -92,7 +97,7 @@
- +
@@ -103,7 +108,7 @@
- +
@@ -116,8 +121,10 @@
@@ -129,13 +136,13 @@
- + - + - +
@@ -156,27 +163,28 @@
- - - + }
+ @let currentRP = currentReadingProfile();
diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index 52c067a16..60f261e51 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -1,32 +1,16 @@ -import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - EventEmitter, - inject, - Inject, - Input, - OnInit, - Output -} from '@angular/core'; -import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {skip, take} from 'rxjs'; +import {NgClass, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit, Signal} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; import {BookTheme} from 'src/app/_models/preferences/book-theme'; import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; import {WritingStyle} from 'src/app/_models/preferences/writing-style'; import {ThemeProvider} from 'src/app/_models/preferences/site-theme'; -import {User} from 'src/app/_models/user'; -import {AccountService} from 'src/app/_services/account.service'; -import {ThemeService} from 'src/app/_services/theme.service'; -import {BookService, FontFamily} from '../../_services/book.service'; +import {FontFamily} from '../../_services/book.service'; import {BookBlackTheme} from '../../_models/book-black-theme'; import {BookDarkTheme} from '../../_models/book-dark-theme'; import {BookWhiteTheme} from '../../_models/book-white-theme'; import {BookPaperTheme} from '../../_models/book-paper-theme'; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { NgbAccordionBody, NgbAccordionButton, @@ -36,11 +20,10 @@ import { NgbAccordionItem, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; -import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; -import {ToastrService} from "ngx-toastr"; +import {BookReadingProfileFormGroup, EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service"; +import {LayoutMode} from "../../../manga-reader/_models/layout-mode"; /** * Used for book reader. Do not use for other components @@ -96,371 +79,109 @@ export const bookColorThemes = [ }, ]; -const mobileBreakpointMarginOverride = 700; - @Component({ selector: 'app-reader-settings', templateUrl: './reader-settings.component.html', styleUrls: ['./reader-settings.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, - NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, + NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, NgClass, NgStyle, TitleCasePipe, TranslocoDirective] }) export class ReaderSettingsComponent implements OnInit { + + private readonly cdRef = inject(ChangeDetectorRef); + @Input({required:true}) seriesId!: number; @Input({required:true}) readingProfile!: ReadingProfile; - /** - * Outputs when clickToPaginate is changed - */ - @Output() clickToPaginateChanged: EventEmitter = new EventEmitter(); - /** - * Outputs when a style is updated and the reader needs to render it - */ - @Output() styleUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when a theme/dark mode is updated - */ - @Output() colorThemeUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when a layout mode is updated - */ - @Output() layoutModeUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when fullscreen is toggled - */ - @Output() fullscreen: EventEmitter = new EventEmitter(); - /** - * Outputs when reading direction is changed - */ - @Output() readingDirection: EventEmitter = new EventEmitter(); - /** - * Outputs when reading mode is changed - */ - @Output() bookReaderWritingStyle: EventEmitter = new EventEmitter(); - /** - * Outputs when immersive mode is changed - */ - @Output() immersiveMode: EventEmitter = new EventEmitter(); + @Input({required:true}) readerSettingsService!: EpubReaderSettingsService; - user!: User; /** * List of all font families user can select from */ fontOptions: Array = []; fontFamilies: Array = []; - /** - * Internal property used to capture all the different css properties to render on all elements - */ - pageStyles!: PageStyle; - - readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight; - - writingStyleModel: WritingStyle = WritingStyle.Horizontal; - - - activeTheme: BookTheme | undefined; - - isFullscreen: boolean = false; - - settingsForm: FormGroup = new FormGroup({}); - - /** - * The reading profile itself, unless readingProfile is implicit - */ - parentReadingProfile: ReadingProfile | null = null; - + settingsForm!: BookReadingProfileFormGroup; /** * System provided themes */ - themes: Array = bookColorThemes; - private readonly destroyRef = inject(DestroyRef); + themes: Array = []; + + protected pageStyles!: Signal; + protected readingDirectionModel!: Signal; + protected writingStyleModel!: Signal; + protected activeTheme!: Signal; + protected layoutMode!: Signal; + protected immersiveMode!: Signal; + protected clickToPaginate!: Signal; + protected isFullscreen!: Signal; + protected canPromoteProfile!: Signal; + protected hasParentProfile!: Signal; + protected parentReadingProfile!: Signal; + protected currentReadingProfile!: Signal; - get BookPageLayoutMode(): typeof BookPageLayoutMode { - return BookPageLayoutMode; - } + async ngOnInit() { + this.pageStyles = this.readerSettingsService.pageStyles; + this.readingDirectionModel = this.readerSettingsService.readingDirection; + this.writingStyleModel = this.readerSettingsService.writingStyle; + this.activeTheme = this.readerSettingsService.activeTheme; + this.layoutMode = this.readerSettingsService.layoutMode; + this.immersiveMode = this.readerSettingsService.immersiveMode; + this.clickToPaginate = this.readerSettingsService.clickToPaginate; + this.isFullscreen = this.readerSettingsService.isFullscreen; + this.canPromoteProfile = this.readerSettingsService.canPromoteProfile; + this.hasParentProfile = this.readerSettingsService.hasParentProfile; + this.parentReadingProfile = this.readerSettingsService.parentReadingProfile; + this.currentReadingProfile = this.readerSettingsService.currentReadingProfile; - get ReadingDirection() { - return ReadingDirection; - } + this.themes = this.readerSettingsService.getThemes(); - get WritingStyle() { - return WritingStyle; - } - constructor(private bookService: BookService, private accountService: AccountService, - @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService, - private toastr: ToastrService) {} - - ngOnInit(): void { - if (this.readingProfile.kind === ReadingProfileKind.Implicit) { - this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => { - this.parentReadingProfile = parent; - this.cdRef.markForCheck(); - }) - } else { - this.parentReadingProfile = this.readingProfile; - this.cdRef.markForCheck(); + // Initialize the service if not already done + if (!this.readerSettingsService.getCurrentReadingProfile()) { + await this.readerSettingsService.initialize(this.seriesId, this.readingProfile); } - this.fontFamilies = this.bookService.getFontFamilies(); + this.settingsForm = this.readerSettingsService.getSettingsForm(); + this.fontFamilies = this.readerSettingsService.getFontFamilies(); this.fontOptions = this.fontFamilies.map(f => f.title); - - - this.cdRef.markForCheck(); - - this.setupSettings(); - - this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false); - this.cdRef.markForCheck(); - - // Emit first time so book reader gets the setting - this.readingDirection.emit(this.readingDirectionModel); - this.bookReaderWritingStyle.emit(this.writingStyleModel); - this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate); - this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode); - this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode); - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.user = user; - } - - // User needs to be loaded before we call this - this.resetSettings(); - }); - } - - setupSettings() { - if (!this.readingProfile) return; - - if (this.readingProfile.bookReaderFontFamily === undefined) { - this.readingProfile.bookReaderFontFamily = 'default'; - } - if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) { - this.readingProfile.bookReaderFontSize = 100; - } - if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) { - this.readingProfile.bookReaderLineSpacing = 100; - } - if (this.readingProfile.bookReaderMargin === undefined) { - this.readingProfile.bookReaderMargin = 0; - } - if (this.readingProfile.bookReaderReadingDirection === undefined) { - this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight; - } - if (this.readingProfile.bookReaderWritingStyle === undefined) { - this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal; - } - this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection; - this.writingStyleModel = this.readingProfile.bookReaderWritingStyle; - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, [])); - this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => { - const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; - if (familyName === 'default') { - this.pageStyles['font-family'] = 'inherit'; - } else { - this.pageStyles['font-family'] = "'" + familyName + "'"; - } - - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, [])); - this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['font-size'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, [])); - this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.clickToPaginateChanged.emit(value); - }); - - this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, [])); - this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['line-height'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, [])); - this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['margin-left'] = value + 'vw'; - this.pageStyles['margin-right'] = value + 'vw'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, [])); - this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => { - this.layoutModeUpdate.emit(layoutMode); - }); - - this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, [])); - this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => { - if (immersiveMode) { - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); - } - this.immersiveMode.emit(immersiveMode); - }); - - // Update implicit reading profile while changing settings - this.settingsForm.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot - takeUntilDestroyed(this.destroyRef), - tap(_ => this.updateImplicit()) - ).subscribe(); } resetSettings() { - if (!this.readingProfile) return; - - if (this.user) { - this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%'); - } else { - this.setPageStyles(); - } - - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily); - this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize); - this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing); - this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin); - this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection); - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate); - this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode); - this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode); - this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle); - - this.cdRef.detectChanges(); - this.styleUpdate.emit(this.pageStyles); - } - - updateImplicit() { - this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({ - next: newProfile => { - this.readingProfile = newProfile; - this.cdRef.markForCheck(); - }, - error: err => { - console.error(err); - } - }) - } - - /** - * Internal method to be used by resetSettings. Pass items in with quantifiers - */ - setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string, colorTheme?: string) { - const windowWidth = window.innerWidth - || this.document.documentElement.clientWidth - || this.document.body.clientWidth; - - - let defaultMargin = '15vw'; - if (windowWidth <= mobileBreakpointMarginOverride) { - defaultMargin = '5vw'; - } - this.pageStyles = { - 'font-family': fontFamily || this.pageStyles['font-family'] || 'default', - 'font-size': fontSize || this.pageStyles['font-size'] || '100%', - 'margin-left': margin || this.pageStyles['margin-left'] || defaultMargin, - 'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin, - 'line-height': lineHeight || this.pageStyles['line-height'] || '100%' - }; + this.readerSettingsService.resetSettings(); } setTheme(themeName: string, update: boolean = true) { - const theme = this.themes.find(t => t.name === themeName); - this.activeTheme = theme; - this.cdRef.markForCheck(); - this.colorThemeUpdate.emit(theme); - - if (update) { - this.updateImplicit(); - } + this.readerSettingsService.setTheme(themeName, update); } toggleReadingDirection() { - if (this.readingDirectionModel === ReadingDirection.LeftToRight) { - this.readingDirectionModel = ReadingDirection.RightToLeft; - } else { - this.readingDirectionModel = ReadingDirection.LeftToRight; - } - - this.cdRef.markForCheck(); - this.readingDirection.emit(this.readingDirectionModel); - this.updateImplicit(); + this.readerSettingsService.toggleReadingDirection(); } toggleWritingStyle() { - if (this.writingStyleModel === WritingStyle.Horizontal) { - this.writingStyleModel = WritingStyle.Vertical - } else { - this.writingStyleModel = WritingStyle.Horizontal - } - - this.cdRef.markForCheck(); - this.bookReaderWritingStyle.emit(this.writingStyleModel); - this.updateImplicit(); + this.readerSettingsService.toggleWritingStyle(); } toggleFullscreen() { - this.isFullscreen = !this.isFullscreen; + this.readerSettingsService.toggleFullscreen(); this.cdRef.markForCheck(); - this.fullscreen.emit(); } // menu only code updateParentPref() { - if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { - return; - } - - this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => { - this.readingProfile = newProfile; - this.toastr.success(translate('manga-reader.reading-profile-updated')); - this.cdRef.markForCheck(); - }); + this.readerSettingsService.updateParentProfile(); } createNewProfileFromImplicit() { - if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { - return; - } - - this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { - this.readingProfile = newProfile; - this.parentReadingProfile = newProfile; // profile is no longer implicit - this.cdRef.markForCheck(); - - this.toastr.success(translate("manga-reader.reading-profile-promoted")); - }); + this.readerSettingsService.createNewProfileFromImplicit(); } - private packReadingProfile(): ReadingProfile { - const modelSettings = this.settingsForm.getRawValue(); - const data = {...this.readingProfile!}; - data.bookReaderFontFamily = modelSettings.bookReaderFontFamily; - data.bookReaderFontSize = modelSettings.bookReaderFontSize - data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing; - data.bookReaderMargin = modelSettings.bookReaderMargin; - data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate; - data.bookReaderLayoutMode = modelSettings.layoutMode; - data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode; - - data.bookReaderReadingDirection = this.readingDirectionModel; - data.bookReaderWritingStyle = this.writingStyleModel; - if (this.activeTheme) { - data.bookReaderThemeName = this.activeTheme.name; - } - - return data; - } protected readonly ReadingProfileKind = ReadingProfileKind; + protected readonly WritingStyle = WritingStyle; + protected readonly ReadingDirection = ReadingDirection; + protected readonly BookPageLayoutMode = BookPageLayoutMode; } diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html index ead8b3540..14f9dc657 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html @@ -1,21 +1,23 @@ - +
- @if (chapters.length === 0) { + @let bookChapters = chapters(); + + @if (bookChapters.length === 0) {
{{t('no-data')}}
- } @else if (chapters.length === 1) { + } @else if (bookChapters.length === 1) {
    - @for(chapter of chapters[0].children; track chapter.title) { -
  • + @for(chapter of bookChapters[0].children; track chapter.title) { +
  • {{chapter.title}}
  • }
} @else { - @for (chapterGroup of chapters; track chapterGroup.title + chapterGroup.children.length) { + @for (chapterGroup of bookChapters; track chapterGroup.title + chapterGroup.children.length) {
  • {{chapterGroup.title}} diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index ce3a180ed..969c52478 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -4,10 +4,8 @@ import { Component, EventEmitter, inject, - Input, - OnChanges, - Output, - SimpleChanges + model, + Output } from '@angular/core'; import {BookChapterItem} from '../../_models/book-chapter-item'; import {TranslocoDirective} from "@jsverse/transloco"; @@ -17,23 +15,19 @@ import {TranslocoDirective} from "@jsverse/transloco"; templateUrl: './table-of-contents.component.html', styleUrls: ['./table-of-contents.component.scss'], imports: [TranslocoDirective], - changeDetection: ChangeDetectionStrategy.Default, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TableOfContentsComponent implements OnChanges { +export class TableOfContentsComponent { private readonly cdRef = inject(ChangeDetectorRef); - @Input({required: true}) chapterId!: number; - @Input({required: true}) pageNum!: number; - @Input({required: true}) currentPageAnchor!: string; - @Input() chapters:Array = []; + chapterId = model.required(); + pageNum = model.required(); + currentPageAnchor = model(); + chapters = model.required>(); @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); - ngOnChanges(changes: SimpleChanges) { - //console.log('Current Page: ', this.pageNum, this.currentPageAnchor); - this.cdRef.markForCheck(); - } cleanIdSelector(id: string) { const tokens = id.split('/'); @@ -44,32 +38,38 @@ export class TableOfContentsComponent implements OnChanges { } loadChapterPage(pageNum: number, part: string) { + this.pageNum.set(pageNum); + this.currentPageAnchor.set(part); + this.loadChapter.emit({pageNum, part}); } isChapterSelected(chapterGroup: BookChapterItem) { - if (chapterGroup.page === this.pageNum) { + const currentPageNum = this.pageNum(); + const chapters = this.chapters(); + + if (chapterGroup.page === currentPageNum) { return true; } - const idx = this.chapters.indexOf(chapterGroup); + const idx = chapters.indexOf(chapterGroup); if (idx < 0) { return false; // should never happen } const nextIdx = idx + 1; // Last chapter - if (nextIdx >= this.chapters.length) { - return chapterGroup.page < this.pageNum; + if (nextIdx >= chapters.length) { + return chapterGroup.page < currentPageNum; } // Passed chapter, and next chapter has not been reached - const next = this.chapters[nextIdx]; - return chapterGroup.page < this.pageNum && next.page > this.pageNum; + const next = chapters[nextIdx]; + return chapterGroup.page < currentPageNum && next.page > currentPageNum; } isAnchorSelected(chapter: BookChapterItem) { - return this.cleanIdSelector(chapter.part) === this.currentPageAnchor + return this.cleanIdSelector(chapter.part) === this.currentPageAnchor(); } } diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html new file mode 100644 index 000000000..a6660af02 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html @@ -0,0 +1,25 @@ + + @let ptoc = bookmark(); + @if (ptoc) { + +
    + +
    +
    + {{ptoc.title}} + +
    +
    + @if (ptoc.chapterTitle) { + {{t('chapter-and-page-title', {chapterTitle: ptoc.chapterTitle, page: ptoc.pageNumber})}} + } @else { + {{t('page-title', {page: ptoc.pageNumber})}} + } +
    +
    +
    + } +
    diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss new file mode 100644 index 000000000..96b69aa0a --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss @@ -0,0 +1,11 @@ +.card:hover { + background-color: var(--elevation-layer7) +} +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; +} + +.content { + color: var(--drawer-text-color) +} diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts new file mode 100644 index 000000000..fb0b91f37 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts @@ -0,0 +1,42 @@ +import {Component, EventEmitter, inject, input, Output} from '@angular/core'; +import {PersonalToC} from "../../../_models/readers/personal-toc"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {ReaderService} from "../../../_services/reader.service"; + +@Component({ + selector: 'app-text-bookmark-item', + imports: [ + NgbTooltip, + TranslocoDirective + ], + templateUrl: './text-bookmark-item.component.html', + styleUrl: './text-bookmark-item.component.scss' +}) +export class TextBookmarkItemComponent { + bookmark = input.required(); + + @Output() loadBookmark = new EventEmitter(); + @Output() removeBookmark = new EventEmitter(); + + private readonly readerService = inject(ReaderService); + + + remove(evt: Event) { + evt.stopPropagation(); + evt.preventDefault(); + + this.removeBookmark.emit(this.bookmark()); + } + + goTo(evt: Event) { + evt.stopPropagation(); + evt.preventDefault(); + + const bookmark = {...this.bookmark()}; + bookmark.bookScrollId = this.readerService.scopeBookReaderXpath(bookmark.bookScrollId ?? ''); + + this.loadBookmark.emit(bookmark); + } + +} diff --git a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts new file mode 100644 index 000000000..7986d6d91 --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts @@ -0,0 +1,35 @@ +export enum HighlightColor { + Blue = 1, + Green = 2, +} + +export const allHighlightColors = [HighlightColor.Blue, HighlightColor.Green]; + + + + +export interface Annotation { + id: number; + xPath: string; + endingXPath: string | null; + selectedText: string | null; + comment: string; + containsSpoiler: boolean; + pageNumber: number; + selectedSlotIndex: number; + chapterTitle: string | null; + highlightCount: number; + ownerUserId: number; + ownerUsername: string; + createdUtc: string; + lastModifiedUtc: string; + /** + * A calculated selection of the surrounding text. This does not update after creation. + */ + context: string | null; + chapterId: number; + libraryId: number; + volumeId: number; + seriesId: number; + +} diff --git a/UI/Web/src/app/book-reader/_models/annotations/highlight-slot.ts b/UI/Web/src/app/book-reader/_models/annotations/highlight-slot.ts new file mode 100644 index 000000000..9a499c6d4 --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/annotations/highlight-slot.ts @@ -0,0 +1,13 @@ +export interface HighlightSlot { + id: number; + title: string; + color: RgbaColor; + slotNumber: number; +} + +export interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} diff --git a/UI/Web/src/app/book-reader/_models/book-info.ts b/UI/Web/src/app/book-reader/_models/book-info.ts index 4816bd324..b0649123b 100644 --- a/UI/Web/src/app/book-reader/_models/book-info.ts +++ b/UI/Web/src/app/book-reader/_models/book-info.ts @@ -1,9 +1,9 @@ -import { MangaFormat } from "src/app/_models/manga-format"; +import {MangaFormat} from "src/app/_models/manga-format"; export interface BookInfo { - bookTitle: string; - seriesFormat: MangaFormat; - seriesId: number; - libraryId: number; - volumeId: number; -} \ No newline at end of file + bookTitle: string; + seriesFormat: MangaFormat; + seriesId: number; + libraryId: number; + volumeId: number; +} diff --git a/UI/Web/src/app/book-reader/_models/create-annotation-request.ts b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts new file mode 100644 index 000000000..555ad3bea --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts @@ -0,0 +1,19 @@ +export interface CreateAnnotationRequest { + libraryId: number; + seriesId: number; + volumeId: number; + chapterId: number; + xpath: string; + endingXPath: string | null; + selectedText: string | null; + comment: string | null; + highlightCount: number; + containsSpoiler: boolean; + pageNumber: number; + selectedSlotIndex: number; + + /** + * Ui Only - the full paragraph of selected context + */ + context: string | null; +} diff --git a/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts b/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts new file mode 100644 index 000000000..b598f36ac --- /dev/null +++ b/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts @@ -0,0 +1,20 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode"; + +@Pipe({ + name: 'columnLayoutClass' +}) +export class ColumnLayoutClassPipe implements PipeTransform { + + transform(value: BookPageLayoutMode): string { + switch (value) { + case BookPageLayoutMode.Default: + return ''; + case BookPageLayoutMode.Column1: + return 'column-layout-1'; + case BookPageLayoutMode.Column2: + return 'column-layout-2'; + } + } + +} diff --git a/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts b/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts new file mode 100644 index 000000000..b84d06feb --- /dev/null +++ b/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts @@ -0,0 +1,18 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {WritingStyle} from "../../_models/preferences/writing-style"; + +@Pipe({ + name: 'writingStyleClass' +}) +export class WritingStyleClassPipe implements PipeTransform { + + transform(value: WritingStyle): string { + switch (value) { + case WritingStyle.Horizontal: + return ''; + case WritingStyle.Vertical: + return 'writing-style-vertical'; + } + } + +} diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index d98f09f38..b7998fd23 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -1,9 +1,9 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { TextResonse } from 'src/app/_types/text-response'; -import { environment } from 'src/environments/environment'; -import { BookChapterItem } from '../_models/book-chapter-item'; -import { BookInfo } from '../_models/book-info'; +import {HttpClient} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; +import {TextResonse} from 'src/app/_types/text-response'; +import {environment} from 'src/environments/environment'; +import {BookChapterItem} from '../_models/book-chapter-item'; +import {BookInfo} from '../_models/book-info'; export interface FontFamily { /** @@ -21,14 +21,15 @@ export interface FontFamily { }) export class BookService { - baseUrl = environment.apiUrl; + private readonly http = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; - constructor(private http: HttpClient) { } getFontFamilies(): Array { return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, - {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; + {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, + {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; } getBookChapters(chapterId: number) { @@ -39,8 +40,8 @@ export class BookService { return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse); } - getBookInfo(chapterId: number) { - return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-info'); + getBookInfo(chapterId: number, includeWordCounts: boolean = false) { + return this.http.get(this.baseUrl + `book/${chapterId}/book-info?includeWordCounts=${includeWordCounts}`); } getBookPageUrl(chapterId: number, page: number) { diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts index 3f76c9cf2..b7d8f23a9 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts @@ -175,7 +175,7 @@ export class BookmarksComponent implements OnInit { const distinctSeriesMap = new Map(); this.bookmarks.forEach(b => { - distinctSeriesMap.set(b.series.id, b.series); + distinctSeriesMap.set(b.series!.id, b.series!); }); this.series = Array.from(distinctSeriesMap.values()); this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (t: Series) => t.name); diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index e16cdff78..903323ce6 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -29,7 +29,7 @@
- diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts index 41451e872..f068efbd6 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts @@ -20,6 +20,7 @@ import {UploadService} from 'src/app/_services/upload.service'; import {DOCUMENT, NgClass} from '@angular/common'; import {ImageComponent} from "../../shared/image/image.component"; import {translate, TranslocoModule} from "@jsverse/transloco"; +import {ColorscapeService} from "../../_services/colorscape.service"; @Component({ selector: 'app-cover-image-chooser', @@ -41,6 +42,7 @@ export class CoverImageChooserComponent implements OnInit { public readonly fb = inject(FormBuilder); public readonly toastr = inject(ToastrService); public readonly uploadService = inject(UploadService); + private readonly colorscapeService = inject(ColorscapeService) /** * If buttons show under images to allow immediate selection of cover images. @@ -95,25 +97,6 @@ export class CoverImageChooserComponent implements OnInit { this.cdRef.markForCheck(); } - - /** - * 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"); - } - selectImage(index: number, callback?: Function) { if (this.selectedIndex === index) { return; } @@ -240,7 +223,7 @@ export class CoverImageChooserComponent implements OnInit { } handleUrlImageAdd(img: HTMLImageElement, index: number = -1) { - const url = this.getBase64Image(img); + const url = this.colorscapeService.getBase64Image(img); if (index >= 0) { this.imageUrls[index] = url; } else { diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html index 8195544af..9bed1b2e3 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html @@ -27,22 +27,22 @@
@if (items.length > 0) {
- + @for(item of items; track item; let i = $index;) { - + - + } @empty { @if (alwaysShow) { - + - + } } - +
}
diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.scss b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.scss index cad8e95bc..e3abe5fb7 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.scss +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.scss @@ -29,15 +29,16 @@ } } -::ng-deep .swiper-slide { - width: auto !important; +::ng-deep swiper-slide { + width: auto !important; + margin: 0 .25rem; } -::ng-deep .swiper-wrapper { +::ng-deep swiper-container { margin-bottom: 10px; } -::ng-deep .last-carousel { +::ng-deep swiper-slide:last-child { margin-bottom: 0; } diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts index 39975022b..b1bb5b42b 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts @@ -3,26 +3,30 @@ import { ChangeDetectorRef, Component, ContentChild, + CUSTOM_ELEMENTS_SCHEMA, EventEmitter, inject, Input, Output, TemplateRef } from '@angular/core'; -import {Swiper, SwiperEvents} from 'swiper/types'; -import {SwiperModule} from 'swiper/angular'; +import {Swiper} from 'swiper/types'; +import {register} from 'swiper/element/bundle'; import {NgClass, NgTemplateOutlet} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {ActionItem} from "../../../_services/action-factory.service"; import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe"; +register(); + @Component({ - selector: 'app-carousel-reel', - templateUrl: './carousel-reel.component.html', - styleUrls: ['./carousel-reel.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgClass, SwiperModule, NgTemplateOutlet, TranslocoDirective, CardActionablesComponent, SafeUrlPipe] + selector: 'app-carousel-reel', + templateUrl: './carousel-reel.component.html', + styleUrls: ['./carousel-reel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgClass, NgTemplateOutlet, TranslocoDirective, CardActionablesComponent, SafeUrlPipe], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class CarouselReelComponent { @@ -75,11 +79,14 @@ export class CarouselReelComponent { this.sectionClick.emit(this.title); } - onSwiper(eventParams: Parameters) { - [this.swiper] = eventParams; - this.cdRef.detectChanges(); + // Swiper new implementation makes it so we need to use a progress event to get initialized + onProgress(event: any) { + let progress = 0; + [this.swiper, progress] = event.detail; + this.cdRef.markForCheck(); } + performAction(action: ActionItem) { this.handleAction.emit(action); } diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 831ee9451..a3f7af74f 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -176,18 +176,33 @@ } -
  • + @let highlights = annotations(); + @if (highlights.length > 0) { +
  • - {{t('reviews-tab')}} - {{userReviews.length + plusReviews.length}} + {{t(TabID.Annotations)}} + {{highlights.length}} - @defer (when activeTabId === TabID.Reviews; prefetch on idle) { - + @defer (when activeTabId === TabID.Annotations; prefetch on idle) { + }
  • + } + +
  • + + {{t('reviews-tab')}} + {{userReviews.length + plusReviews.length}} + + + @defer (when activeTabId === TabID.Reviews; prefetch on idle) { + + } + +
  • @if(readingLists.length > 0) {
  • diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index f5bd1bb6b..c79d05cc6 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -5,6 +5,7 @@ import { DestroyRef, ElementRef, inject, + model, OnInit, ViewChild } from '@angular/core'; @@ -75,11 +76,15 @@ import {User} from "../_models/user"; import {ReviewsComponent} from "../_single-module/reviews/reviews.component"; import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component"; import {Rating} from "../_models/rating"; +import {AnnotationService} from "../_services/annotation.service"; +import {Annotation} from "../book-reader/_models/annotations/annotation"; +import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component"; enum TabID { Related = 'related-tab', Reviews = 'review-tab', - Details = 'details-tab' + Details = 'details-tab', + Annotations = 'annotations-tab' } @Component({ @@ -114,7 +119,8 @@ enum TabID { DefaultDatePipe, CoverImageComponent, ReviewsComponent, - ExternalRatingComponent + ExternalRatingComponent, + AnnotationsTabComponent ], templateUrl: './chapter-detail.component.html', styleUrl: './chapter-detail.component.scss', @@ -145,6 +151,7 @@ export class ChapterDetailComponent implements OnInit { private readonly actionFactoryService = inject(ActionFactoryService); private readonly actionService = inject(ActionService); private readonly location = inject(Location); + private readonly annotationService = inject(AnnotationService); protected readonly AgeRating = AgeRating; protected readonly TabID = TabID; @@ -170,6 +177,7 @@ export class ChapterDetailComponent implements OnInit { rating: number = 0; ratings: Array = []; hasBeenRated: boolean = false; + annotations = model([]); weblinks: Array = []; activeTabId = TabID.Details; @@ -212,6 +220,8 @@ export class ChapterDetailComponent implements OnInit { return; } + + this.mobileSeriesImgBackground = getComputedStyle(document.documentElement) .getPropertyValue('--mobile-series-img-background').trim(); this.seriesId = parseInt(seriesId, 10); @@ -219,6 +229,10 @@ export class ChapterDetailComponent implements OnInit { this.libraryId = parseInt(libraryId, 10); this.coverImage = this.imageService.getChapterCoverImage(this.chapterId); + this.annotationService.getAllAnnotations(this.chapterId).subscribe(annotations => { + this.annotations.set(annotations); + }); + this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { if (event.event === EVENTS.CoverUpdate) { const coverUpdateEvent = event.payload as CoverUpdateEvent; diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 89041956c..44e0e49aa 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -24,7 +24,7 @@ }
  • - {{subtitle}} + {{chapterTitleLabel()}} @if (totalSeriesPages > 0) { {{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }} } @@ -32,11 +32,11 @@
    - - @if (!bookmarkMode && hasBookmarkRights) { + @if (!bookmarkMode() && hasBookmarkRights) { +
    +} @else { + + +} diff --git a/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.scss b/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.scss new file mode 100644 index 000000000..45955179b --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.scss @@ -0,0 +1,122 @@ +$dotSize: 30px; + +.color-picker-container { + position: relative; + display: inline-block; +} + +.floating-picker { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + margin-bottom: 4px; + padding: 1px; + + background: var(--drawer-bg-color); + border: 2px solid var(--primary-color); + border-radius: var(--card-border-radius); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.4), + 0 4px 16px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +// This doesn't seem to work +// @media (max-width: var(--setting-mobile-breakpoint)px) { +@media (max-width: 768px) { + .floating-picker.left { + transform: translateX(-25%); + } + + .floating-picker.right { + transform: translateX(-75%); + } +} + +:host ::ng-deep chrome-picker { + + rgba-input-component { + + span.ng-star-inserted { + color: var(--drawer-text-color); + } + + } + + input { + background: var(--drawer-bg-color); + color: var(--drawer-text-color); + border-radius: 4px; + border: 1px solid var(--input-border-color); + } + + span.type-btn { + background: var(--drawer-bg-color); + color: var(--drawer-text-color); + } + + span.type-btn:hover { + background: var(--drawer-bg-color); + color: var(--drawer-text-color); + } + + span.type-btn::after { + content: '\f2f1'; /* FA Cycle */ + font-family: "Font Awesome 5 Free", serif; + font-weight: 900; + color: var(--drawer-text-color); + + display: inline-block; + position: relative; + top: -10px; + } + + .gradient-color, + hue-component, + indicator-component { + padding: 1px; + border: 1px solid var(--input-border-color); + } + + +} + +.btn.btn-icon { + display: flex; + justify-content: center; + align-items: center; + + &.color { + display: unset; + width: auto; + + .dot { + height: $dotSize; + width: $dotSize; + border-radius: 50%; + margin: 0 auto; + cursor: pointer; + } + + .dot-add { + i { + color: var(--primary-color); + } + border: 2px solid var(--primary-color); + + &:hover { + background-color: var(--primary-color); + i { + color: var(--secondary-color); + } + } + } + } +} + +.active { + border: 2px solid var(--primary-color); +} + diff --git a/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.ts b/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.ts new file mode 100644 index 000000000..f8935fc89 --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-colour-picker/setting-color-picker.component.ts @@ -0,0 +1,125 @@ +import { + Component, DestroyRef, + effect, ElementRef, + EventEmitter, + HostListener, + inject, + input, + model, + OnInit, + Output, + signal, + ViewChild +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {SlotColorPipe} from "../../../_pipes/slot-color.pipe"; +import {RgbaColor} from "../../../book-reader/_models/annotations/highlight-slot"; +import {LongClickDirective} from "../../../_directives/long-click.directive"; +import {ChromePickerComponent, Color, ColorPickerControl} from "@iplab/ngx-color-picker"; +import {UserBreakpoint, UtilityService} from "../../../shared/_services/utility.service"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {debounceTime, distinctUntilChanged} from "rxjs/operators"; +import {tap} from "rxjs"; + +@Component({ + selector: 'app-setting-colour-picker', + standalone: true, + imports: [CommonModule, SlotColorPipe, LongClickDirective, ChromePickerComponent], + templateUrl: './setting-color-picker.component.html', + styleUrl: './setting-color-picker.component.scss' +}) +export class SettingColorPickerComponent implements OnInit { + + private readonly elementRef = inject(ElementRef); + private readonly slotColorPipe = inject(SlotColorPipe); + private readonly destroyRef = inject(DestroyRef); + private readonly utilityService: UtilityService = inject(UtilityService); + + @ViewChild('colorPopup') colorPopup?: ElementRef; + + id = input.required(); + label = input.required(); + /** + * Moves the pop-up right on mobile devices + */ + first = input(false); + /** + * Moves the pop-up left on mobile devices + */ + last = input(false); + + editMode = model(false); + color = model.required(); + /** + * If the edit mode can be changed due to user input + */ + canChangeEditMode = input(true); + selected = input.required(); + + showPicker = signal(false); + + @Output() selectPicker = new EventEmitter(); + /** + * Emits the raw color from the color picker rather than our RgbaColor + */ + @Output() rawColorChange = new EventEmitter(); + + chromeControl!: ColorPickerControl; + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event) { + if (!this.showPicker()) return; + + if (!this.colorPopup) return; + + const clickedElement = event.target as Node; + + if (!this.elementRef.nativeElement.contains(clickedElement) && !this.colorPopup.nativeElement.contains(clickedElement)) { + this.showPicker.set(false); + } + } + + onSelect() { + this.selectPicker.emit(); + } + + longClick() { + if (!this.canChangeEditMode()) return; + + this.editMode.update(b => !b); + + if (this.utilityService.activeUserBreakpoint() < UserBreakpoint.Desktop) { + this.showPicker.update(b => !b); + } + } + + togglePicker() { + this.showPicker.update(b => !b); + } + + ngOnInit() { + this.chromeControl = new ColorPickerControl() + .setValueFrom(this.slotColorPipe.transform(this.color())) + .showAlphaChannel() + .hidePresets(); + + this.chromeControl.valueChanges + .pipe( + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + debounceTime(500), // TODO: Find a fitting time, or move to explicit save? + tap((color) => { + const rgba: RgbaColor = { + a: color.getRgba().alpha, + r: Math.floor(color.getRgba().red), + g: Math.floor(color.getRgba().green), + b: Math.floor(color.getRgba().blue), + }; + + this.color.set(rgba); + this.rawColorChange.emit(color); + }), + ) + .subscribe() + } +} diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index d83eaea73..647130f1d 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,5 +1,5 @@ import {HttpClient} from '@angular/common/http'; -import {DestroyRef, inject, Inject, Injectable} from '@angular/core'; +import {DestroyRef, inject, Injectable} from '@angular/core'; import {Series} from 'src/app/_models/series'; import {environment} from 'src/environments/environment'; import {ConfirmService} from '../confirm.service'; @@ -13,12 +13,13 @@ import {AccountService} from 'src/app/_services/account.service'; import {BytesPipe} from 'src/app/_pipes/bytes.pipe'; import {translate} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {SAVER, Saver} from "../../_providers/saver.provider"; +import {SAVER} from "../../_providers/saver.provider"; import {UtilityService} from "./utility.service"; import {UserCollection} from "../../_models/collection-tag"; import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; +import {ReaderService} from "../../_services/reader.service"; export const DEBOUNCE_TIME = 100; @@ -86,8 +87,10 @@ export class DownloadService { private readonly accountService = inject(AccountService); private readonly httpClient = inject(HttpClient); private readonly utilityService = inject(UtilityService); + private readonly readerService = inject(ReaderService); + private readonly save = inject(SAVER); - constructor(@Inject(SAVER) private save: Saver) { + constructor() { this.downloadQueue.subscribe((queue) => { if (queue.length > 0) { const entity = queue.shift(); @@ -174,12 +177,14 @@ export class DownloadService { switchMap(() => { return (downloadCall || of(undefined)).pipe( tap((d) => { + this.readerService.enableWakeLock(); if (callback) callback(d); }), takeWhile((val: Download) => { return val.state != 'DONE'; }), finalize(() => { + this.readerService.disableWakeLock(); if (callback) callback(undefined); })) }), takeUntilDestroyed(this.destroyRef) diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index 8c2d00c02..b80da5c76 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -122,7 +122,7 @@
    @for (group of fileTypeGroups; track group) {
    - +
    } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 98e52f5ba..b00194434 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -401,7 +401,7 @@ export class LibrarySettingsModalComponent implements OnInit { } applyCoverImage(coverUrl: string) { - this.uploadService.updateLibraryCoverImage(this.library!.id, coverUrl).subscribe(() => {}); + this.uploadService.updateLibraryCoverImage(this.library!.id, coverUrl).subscribe(); } updateCoverImageIndex(selectedIndex: number) { @@ -410,7 +410,7 @@ export class LibrarySettingsModalComponent implements OnInit { } resetCoverImage() { - this.uploadService.updateLibraryCoverImage(this.library!.id, '', false).subscribe(() => {}); + this.uploadService.updateLibraryCoverImage(this.library!.id, '', false).subscribe(); } openDirectoryPicker() { diff --git a/UI/Web/src/app/user-settings/change-password/change-password.component.html b/UI/Web/src/app/user-settings/change-password/change-password.component.html index 328131cf9..b5373c957 100644 --- a/UI/Web/src/app/user-settings/change-password/change-password.component.html +++ b/UI/Web/src/app/user-settings/change-password/change-password.component.html @@ -1,4 +1,4 @@ - + @for (opt of readingDirections; track opt) { - + } @@ -99,7 +99,7 @@ @@ -115,7 +115,7 @@ @@ -131,7 +131,7 @@ @@ -147,7 +147,7 @@ @@ -163,10 +163,15 @@
    - + @@ -261,7 +266,7 @@ @@ -312,7 +317,7 @@ @@ -328,7 +333,7 @@ @@ -344,7 +349,7 @@ @@ -360,7 +365,7 @@ @@ -376,7 +381,7 @@ @@ -454,7 +459,7 @@ @@ -470,7 +475,7 @@ @@ -486,7 +491,7 @@ diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts index 0db941653..eb5d262ac 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts @@ -40,7 +40,6 @@ import {ScalingOptionPipe} from "../../_pipes/scaling-option.pipe"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {WritingStylePipe} from "../../_pipes/writing-style.pipe"; -import {ColorPickerDirective} from "ngx-color-picker"; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {catchError, filter, of, switchMap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -49,6 +48,11 @@ import {ToastrService} from "ngx-toastr"; import {ConfirmService} from "../../shared/confirm.service"; import {WikiLink} from "../../_models/wiki"; import {BreakpointPipe} from "../../_pipes/breakpoint.pipe"; +import { + SettingColorPickerComponent +} from "../../settings/_components/setting-colour-picker/setting-color-picker.component"; +import {ColorscapeService} from "../../_services/colorscape.service"; +import {Color} from "@iplab/ngx-color-picker"; enum TabId { ImageReader = "image-reader", @@ -79,7 +83,6 @@ enum TabId { TitleCasePipe, WritingStylePipe, NgStyle, - ColorPickerDirective, NgbNav, NgbNavItem, NgbNavLinkBase, @@ -88,6 +91,7 @@ enum TabId { LoadingComponent, NgbTooltip, BreakpointPipe, + SettingColorPickerComponent, ], templateUrl: './manage-reading-profiles.component.html', styleUrl: './manage-reading-profiles.component.scss', @@ -96,6 +100,7 @@ enum TabId { export class ManageReadingProfilesComponent implements OnInit { private readonly readingProfileService = inject(ReadingProfileService); + protected readonly colorscapeService = inject(ColorscapeService); private readonly cdRef = inject(ChangeDetectorRef); private readonly accountService = inject(AccountService); private readonly bookService = inject(BookService); @@ -287,13 +292,13 @@ export class ManageReadingProfilesComponent implements OnInit { return data; } - handleBackgroundColorChange(color: string) { + handleBackgroundColorChange(color: Color) { if (!this.readingProfileForm || !this.selectedProfile) return; this.readingProfileForm.markAsDirty(); this.readingProfileForm.markAsTouched(); - this.selectedProfile.backgroundColor = color; - this.readingProfileForm.get('backgroundColor')?.setValue(color); + this.selectedProfile.backgroundColor = color.toHexString(); + this.readingProfileForm.get('backgroundColor')?.setValue(color.toHexString()); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index d808ebd2d..bd407f98c 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -72,24 +72,33 @@ -
    - - -
    - -
    -
    -
    +
    +
    +
    +

    {{t('social-settings-title')}}

    +
    + + +
    + +
    +
    +
    +
    + + + @if (licenseService.hasValidLicense$ | async) { -

    {{t('kavitaplus-settings-title')}}

    +
    +

    {{t('kavitaplus-settings-title')}}

    @if(settingsForm.get('aniListScrobblingEnabled'); as formControl) { diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 15ed323c8..84d030469 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -1,46 +1,22 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; -import {translate, TranslocoDirective} from "@jsverse/transloco"; -import { - Preferences -} from "../../_models/preferences/preferences"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {Preferences} from "../../_models/preferences/preferences"; import {AccountService} from "../../_services/account.service"; import {BookService} from "../../book-reader/_services/book.service"; import {Title} from "@angular/platform-browser"; import {Router} from "@angular/router"; import {LocalizationService} from "../../_services/localization.service"; -import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; import {User} from "../../_models/user"; import {KavitaLocale} from "../../_models/metadata/language"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {debounceTime, distinctUntilChanged, filter, forkJoin, switchMap, tap} from "rxjs"; import {take} from "rxjs/operators"; -import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode"; -import {PdfTheme} from "../../_models/preferences/pdf-theme"; -import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode"; -import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode"; -import {AsyncPipe, DecimalPipe, NgStyle, TitleCasePipe} from "@angular/common"; +import {AsyncPipe, DecimalPipe, TitleCasePipe} from "@angular/common"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import {ReadingDirectionPipe} from "../../_pipes/reading-direction.pipe"; -import {ScalingOptionPipe} from "../../_pipes/scaling-option.pipe"; -import {PageSplitOptionPipe} from "../../_pipes/page-split-option.pipe"; -import {ReaderModePipe} from "../../_pipes/reading-mode.pipe"; -import {LayoutModePipe} from "../../_pipes/layout-mode.pipe"; -import {WritingStylePipe} from "../../_pipes/writing-style.pipe"; -import {BookPageLayoutModePipe} from "../../_pipes/book-page-layout-mode.pipe"; -import {PdfSpreadModePipe} from "../../_pipes/pdf-spread-mode.pipe"; -import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe"; -import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe"; import {LicenseService} from "../../_services/license.service"; -import {ColorPickerDirective} from "ngx-color-picker"; -import { - bookLayoutModes, bookWritingStyles, - layoutModes, pageSplitOptions, - pdfScrollModes, - pdfSpreadModes, - pdfThemes, readingDirections, readingModes, scalingOptions -} from "../../_models/preferences/reading-profiles"; +import {HighlightBarComponent} from "../../book-reader/_components/_annotations/highlight-bar/highlight-bar.component"; @Component({ selector: 'app-manga-user-preferences', @@ -50,20 +26,9 @@ import { TitleCasePipe, SettingItemComponent, SettingSwitchComponent, - ReadingDirectionPipe, - ScalingOptionPipe, - PageSplitOptionPipe, - ReaderModePipe, - LayoutModePipe, - NgStyle, - WritingStylePipe, - BookPageLayoutModePipe, - PdfSpreadModePipe, - PdfThemePipe, - PdfScrollModePipe, AsyncPipe, DecimalPipe, - ColorPickerDirective + HighlightBarComponent ], templateUrl: './manage-user-preferences.component.html', styleUrl: './manage-user-preferences.component.scss', @@ -136,6 +101,7 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.addControl('aniListScrobblingEnabled', new FormControl(this.user.preferences.aniListScrobblingEnabled || false, [])); this.settingsForm.addControl('wantToReadSync', new FormControl(this.user.preferences.wantToReadSync || false, [])); + this.settingsForm.addControl('bookReaderHighlightSlots', new FormControl(this.user.preferences.bookReaderHighlightSlots, [])); // Automatically save settings as we edit them @@ -171,33 +137,6 @@ export class ManageUserPreferencesComponent implements OnInit { reset() { if (!this.user) return; - /*this.settingsForm.get('readingDirection')?.setValue(this.user.preferences.readingDirection, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('scalingOption')?.setValue(this.user.preferences.scalingOption, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('autoCloseMenu')?.setValue(this.user.preferences.autoCloseMenu, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('showScreenHints')?.setValue(this.user.preferences.showScreenHints, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('readerMode')?.setValue(this.user.preferences.readerMode, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('layoutMode')?.setValue(this.user.preferences.layoutMode, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('allowAutomaticWebtoonReaderDetection')?.setValue(this.user.preferences.allowAutomaticWebtoonReaderDetection, {onlySelf: true, emitEvent: false}); - - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.user.preferences.bookReaderLineSpacing, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderThemeName')?.setValue(this.user?.preferences.bookReaderThemeName || bookColorThemes[0].name, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user?.preferences.bookReaderImmersiveMode, {onlySelf: true, emitEvent: false}); - - this.settingsForm.get('pdfTheme')?.setValue(this.user?.preferences.pdfTheme || PdfTheme.Dark, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('pdfScrollMode')?.setValue(this.user?.preferences.pdfScrollMode || PdfScrollMode.Vertical, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('pdfSpreadMode')?.setValue(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, {onlySelf: true, emitEvent: false});*/ - this.settingsForm.get('theme')?.setValue(this.user.preferences.theme, {onlySelf: true, emitEvent: false}); this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode, {onlySelf: true, emitEvent: false}); this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries, {onlySelf: true, emitEvent: false}); @@ -209,45 +148,24 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.get('aniListScrobblingEnabled')?.setValue(this.user.preferences.aniListScrobblingEnabled || false, {onlySelf: true, emitEvent: false}); this.settingsForm.get('wantToReadSync')?.setValue(this.user.preferences.wantToReadSync || false, {onlySelf: true, emitEvent: false}); + this.settingsForm.get('bookReaderHighlightSlots')?.setValue(this.user.preferences.bookReaderHighlightSlots, {onlySelf: true, emitEvent: false}); } packSettings(): Preferences { const modelSettings = this.settingsForm.value; + return { - /*readingDirection: parseInt(modelSettings.readingDirection, 10), - scalingOption: parseInt(modelSettings.scalingOption, 10), - pageSplitOption: parseInt(modelSettings.pageSplitOption, 10), - autoCloseMenu: modelSettings.autoCloseMenu, - readerMode: parseInt(modelSettings.readerMode, 10), - layoutMode: parseInt(modelSettings.layoutMode, 10), - showScreenHints: modelSettings.showScreenHints, - allowAutomaticWebtoonReaderDetection: modelSettings.allowAutomaticWebtoonReaderDetection, - backgroundColor: modelSettings.backgroundColor || '#000', - bookReaderFontFamily: modelSettings.bookReaderFontFamily, - bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, - bookReaderFontSize: modelSettings.bookReaderFontSize, - bookReaderMargin: modelSettings.bookReaderMargin, - bookReaderTapToPaginate: modelSettings.bookReaderTapToPaginate, - bookReaderReadingDirection: parseInt(modelSettings.bookReaderReadingDirection, 10), - bookReaderWritingStyle: parseInt(modelSettings.bookReaderWritingStyle, 10), - bookReaderLayoutMode: parseInt(modelSettings.bookReaderLayoutMode, 10), - bookReaderThemeName: modelSettings.bookReaderThemeName,*/ theme: modelSettings.theme, - //bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode, globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10), blurUnreadSummaries: modelSettings.blurUnreadSummaries, promptForDownloadSize: modelSettings.promptForDownloadSize, noTransitions: modelSettings.noTransitions, - //emulateBook: modelSettings.emulateBook, - //swipeToPaginate: modelSettings.swipeToPaginate, collapseSeriesRelationships: modelSettings.collapseSeriesRelationships, shareReviews: modelSettings.shareReviews, locale: modelSettings.locale || 'en', - //pdfTheme: parseInt(modelSettings.pdfTheme, 10), - //pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10), - //pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10), aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled, wantToReadSync: modelSettings.wantToReadSync, + bookReaderHighlightSlots: modelSettings.bookReaderHighlightSlots, }; } } diff --git a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html index 294a2223d..a1a34046f 100644 --- a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html +++ b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html @@ -24,7 +24,7 @@
    - +
    diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index 56742bc57..0e9fb51da 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -1,4 +1,4 @@ - + @@ -180,31 +180,45 @@ - @if (volume.chapters.length === 1 && readingLists.length > 0) { -
  • - {{t('related-tab')}} - - @defer (when activeTabId === TabID.Related; prefetch on idle) { - - } - -
  • - } + @if (volume.chapters.length === 1 && readingLists.length > 0) { +
  • + {{t('related-tab')}} + + @defer (when activeTabId === TabID.Related; prefetch on idle) { + + } + +
  • + } - @if (volume.chapters.length === 1) { -
  • - - {{t('reviews-tab')}} - {{userReviews.length + plusReviews.length}} - - - @defer (when activeTabId === TabID.Reviews; prefetch on idle) { - - } - -
  • - } + @if (volume.chapters.length === 1 && annotations().length > 0) { +
  • + + {{t(TabID.Annotations)}} + {{annotations().length}} + + + @defer (when activeTabId === TabID.Annotations; prefetch on idle) { + + } + +
  • + } + + @if (volume.chapters.length === 1) { +
  • + + {{t('reviews-tab')}} + {{userReviews.length + plusReviews.length}} + + + @defer (when activeTabId === TabID.Reviews; prefetch on idle) { + + } + +
  • + }
  • {{t('details-tab')}} diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 7460bdcab..a932e008c 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -5,6 +5,7 @@ import { DestroyRef, ElementRef, inject, + model, OnInit, ViewChild } from '@angular/core'; @@ -81,6 +82,9 @@ import {ReviewsComponent} from "../_single-module/reviews/reviews.component"; import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component"; import {ChapterService} from "../_services/chapter.service"; import {User} from "../_models/user"; +import {AnnotationService} from "../_services/annotation.service"; +import {Annotation} from "../book-reader/_models/annotations/annotation"; +import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component"; enum TabID { @@ -88,6 +92,7 @@ enum TabID { Related = 'related-tab', Reviews = 'reviews-tab', // Only applicable for books Details = 'details-tab', + Annotations = 'annotations-tab' } interface VolumeCast extends IHasCast { @@ -152,7 +157,8 @@ interface VolumeCast extends IHasCast { BulkOperationsComponent, CoverImageComponent, ReviewsComponent, - ExternalRatingComponent + ExternalRatingComponent, + AnnotationsTabComponent ], templateUrl: './volume-detail.component.html', styleUrl: './volume-detail.component.scss', @@ -182,6 +188,7 @@ export class VolumeDetailComponent implements OnInit { private readonly messageHub = inject(MessageHubService); private readonly location = inject(Location); private readonly chapterService = inject(ChapterService); + private readonly annotationService = inject(AnnotationService); protected readonly AgeRating = AgeRating; @@ -209,6 +216,7 @@ export class VolumeDetailComponent implements OnInit { plusReviews: Array = []; rating: number = 0; hasBeenRated: boolean = false; + annotations = model([]); mobileSeriesImgBackground: string | undefined; downloadInProgress: boolean = false; @@ -361,6 +369,7 @@ export class VolumeDetailComponent implements OnInit { this.libraryId = parseInt(libraryId, 10); this.coverImage = this.imageService.getVolumeCoverImage(this.volumeId); + this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { if (event.event === EVENTS.CoverUpdate) { const coverUpdateEvent = event.payload as CoverUpdateEvent; @@ -407,6 +416,11 @@ export class VolumeDetailComponent implements OnInit { this.rating = detail.rating; this.hasBeenRated = detail.hasBeenRated; }); + + this.annotationService.getAllAnnotations(this.volume.chapters[0].id).subscribe(annotations => { + this.annotations.set(annotations); + }); + } this.themeService.setColorScape(this.volume!.primaryColor, this.volume!.secondaryColor); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 2425379bb..6eb3f880a 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -188,6 +188,7 @@ "success-toast": "User preferences updated", "global-settings-title": "Global Settings", + "social-settings-title": "Social Settings", "page-layout-mode-label": "Page Layout Mode", "page-layout-mode-tooltip": "Show items as cards or list view on Series Detail page.", "locale-label": "Locale", @@ -201,15 +202,15 @@ "collapse-series-relationships-label": "Collapse Series Relationships", "collapse-series-relationships-tooltip": "Should Kavita show Series that have no relationships or is the parent/prequel", "share-series-reviews-label": "Share Series Reviews", - "share-series-reviews-tooltip": "Should Kavita include your reviews of Series for other users", + "share-series-reviews-tooltip": "Allow your reviews to be visible to other users on this server", "kavitaplus-settings-title": "Kavita+", "anilist-scrobbling-label": "AniList Scrobbling", "anilist-scrobbling-tooltip": "Allow Kavita to Scrobble (one-way sync) reading progress and ratings to AniList", "want-to-read-sync-label": "Want To Read Sync", - "want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending readlist", + "want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending read list", - "clients-opds-alert": "OPDS is not enabled on this server. This will not affect Tachiyomi users.", + "clients-opds-alert": "OPDS is not enabled on this server. This will not affect Mihon users.", "clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.", "clients-api-key-tooltip": "The API key is like a password. Resetting it will invalidate any existing clients.", "clients-opds-url-label": "OPDS URL", @@ -882,12 +883,71 @@ "book-line-overlay": { "copy": "Copy", "bookmark": "Bookmark", + "annotate": "Annotate", "close": "{{common.close}}", "required-field": "{{common.required-field}}", "bookmark-label": "Bookmark Name", "save": "{{common.save}}" }, + "annotation-card": { + "page-num": "{{book-reader.page-num-label}}", + "contains-spoilers-label": "Spoilers", + "view-in-reader-label": "View in Reader" + }, + + "annotations-tab": { + "title": "{{tabs.annotations-tab}}" + }, + + "view-annotations-drawer": { + "title": "{{tabs.annotations-tab}}", + "close": "{{common.close}}", + "filter-label": "Filter Annotations", + "no-data": "{{common.no-data}}" + }, + + "view-edit-annotation-drawer": { + "view-title": "View Annotation", + "edit-title": "Edit Annotation", + "create-title": "Create Annotation", + "create": "Create", + "close": "{{common.close}}", + "contains-spoilers-label": "{{annotation-card.contains-spoilers-label}}" + }, + + "view-bookmark-drawer": { + "title": "Bookmarks", + "close": "{{common.close}}", + "no-data": "{{common.no-data}}", + "page-num-label": "{{book-reader.page-num-label}}", + "load-bookmark": "Load", + "image-header": "Image", + "text-header": "Text" + }, + + "page-chapter-label-pipe": { + "page-only": "— Page {{pageNumber}}", + "full": "— Page {{pageNumber}}, Chapter \"{{chapterTitle}}\"" + }, + + "view-toc-drawer": { + "title": "Table of Contents", + "close": "{{common.close}}", + "toc-header": "Book", + "personal-header": "Personal" + }, + + "epub-setting-drawer": { + "title": "Book Settings", + "close": "{{common.close}}" + }, + + "highlight-bar": { + "add-new-color-alt": "Add Custom Color", + "slot-label": "Highlight slot {{slot}}" + }, + "book-reader": { "title": "Book Settings", "page-label": "Page", @@ -916,16 +976,21 @@ "previous": "Previous", "go-to-page": "Go to page", - "go-to-page-prompt": "There are {{totalPages}} pages. What page do you want to go to?", + "go-to-page-prompt": "There are {{totalPages}} pages. Which page do you want to go to?", - "go-to-section": "Go to section", - "go-to-section-prompt": "There are {{totalSections}} sections. What section do you want to go to?" + "page-num-label": "Page {{page}}", + "completion-label": "{{percent}} complete", + + "force-selected-one-column": "Layout mode switched to One Column due to insufficient space to render Two Columns" }, "personal-table-of-contents": { "no-data": "Nothing Bookmarked yet", - "page": "Page {{value}}", - "delete": "Delete {{bookmarkName}}" + "page-title": "Page {{page}}", + "chapter-and-page-title": "Chapter {{chapterTitle}}, Page {{page}}", + "delete": "Delete {{bookmarkName}}", + "no-match": "No Bookmarks match filter", + "filter-label": "{{common.filter}}" }, "confirm-email": { @@ -993,6 +1058,7 @@ "specials-tab": "{{tabs.specials-tab}}", "related-tab": "{{tabs.related-tab}}", "details-tab": "{{tabs.details-tab}}", + "annotations-tab": "{{tabs.annotations-tab}}", "info-tab": "{{tabs.info-tab}}", "recommendations-tab": "{{tabs.recommendations-tab}}", "send-to": "File emailed to {{deviceName}}", @@ -1925,6 +1991,7 @@ "chapters": "Chapters", "people": "People", "tags": "Tags", + "annotations": "{{tabs.annotations-tab}}", "genres": "{{metadata-fields.genres-title}}", "bookmarks": "{{side-nav.bookmarks}}", "libraries": "Libraries", @@ -2893,13 +2960,17 @@ "scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.", "series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}", "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}", - "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes." + "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes.", + "confirm-delete-bookmark": "Are you sure you want to delete this Bookmark?", + "confirm-delete-annotation": "Are you sure you want to delete this Annotation?" }, "read-time-pipe": { "less-than-hour": "<1 Hour", "hour": "Hour", - "hours": "Hours" + "hours": "Hours", + "hour-left": "Hour left", + "hours-left": "Hours left" }, "metadata-setting-field-pipe": { @@ -3163,7 +3234,8 @@ "devices-tab": "Devices", "stats-tab": "Stats", "scrobbling-tab": "Scrobbling", - "smart-filters-tab": "Smart Filters" + "smart-filters-tab": "Smart Filters", + "annotations-tab": "Annotations" }, "common": { diff --git a/UI/Web/src/httpLoader.ts b/UI/Web/src/httpLoader.ts index ff9ceea49..a818bcdd8 100644 --- a/UI/Web/src/httpLoader.ts +++ b/UI/Web/src/httpLoader.ts @@ -1,4 +1,4 @@ -import {Injectable} from "@angular/core"; +import {inject, Injectable} from "@angular/core"; import {HttpClient} from "@angular/common/http"; import {Translation, TranslocoLoader} from "@jsverse/transloco"; import cacheBusting from 'i18n-cache-busting.json'; // allowSyntheticDefaultImports must be true diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index 36de060ec..9b997fd51 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -1,27 +1,30 @@ -/// import {ApplicationConfig, importProvidersFrom, inject, provideAppInitializer,} from '@angular/core'; import {AppComponent} from './app/app.component'; import {NgCircleProgressModule} from 'ng-circle-progress'; -import {ToastrModule, ToastrService} from 'ngx-toastr'; +import {ToastrModule} from 'ngx-toastr'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {AppRoutingModule} from './app/app-routing.module'; import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser'; import {JwtInterceptor} from './app/_interceptors/jwt.interceptor'; import {ErrorInterceptor} from './app/_interceptors/error.interceptor'; -import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; -import {provideTransloco, translate, TranslocoConfig, TranslocoService} from "@jsverse/transloco"; +import {HTTP_INTERCEPTORS, provideHttpClient, withFetch, withInterceptorsFromDi} from '@angular/common/http'; +import {provideTransloco, TranslocoConfig, TranslocoService} from "@jsverse/transloco"; import {environment} from "./environments/environment"; import {AccountService} from "./app/_services/account.service"; -import {catchError, filter, firstValueFrom, Observable, of, switchMap, take, tap, timeout} from "rxjs"; +import {catchError, firstValueFrom, of, switchMap, tap} from "rxjs"; import {provideTranslocoLocale} from "@jsverse/transloco-locale"; import {LazyLoadImageModule} from "ng-lazyload-image"; import {getSaver, SAVER} from "./app/_providers/saver.provider"; import {APP_BASE_HREF, PlatformLocation} from "@angular/common"; import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations'; import {HttpLoader} from "./httpLoader"; -import {SettingsService} from "./app/admin/settings.service"; +import {register as registerSwiperElements} from 'swiper/element/bundle'; +import {ColorPickerModule} from "@iplab/ngx-color-picker"; + const disableAnimations = !('animate' in document.documentElement); +registerSwiperElements(); + function transformLanguageCodes(arr: Array) { const transformedArray: Array = []; @@ -129,6 +132,7 @@ bootstrapApplication(AppComponent, { autoDismiss: true }), NgCircleProgressModule.forRoot(), + ColorPickerModule, ), provideTransloco(translocoOptions), provideTranslocoLocale({ @@ -148,7 +152,7 @@ bootstrapApplication(AppComponent, { useFactory: getBaseHref, deps: [PlatformLocation] }, - provideHttpClient(withInterceptorsFromDi()), + provideHttpClient(withInterceptorsFromDi(), withFetch()), provideAppInitializer(() => bootstrapUser()), ] } as ApplicationConfig) diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index eef1b565d..cac4fd9dd 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -1,4 +1,4 @@ -@use '../node_modules/swiper/swiper' as swiper; +@use 'swiper/swiper-bundle.min' as swiper; @use './theme/variables' as theme; @@ -8,6 +8,8 @@ // Import colors for overrides of bootstrap theme @use './theme/toastr' as toastr; +@use '../node_modules/quill/dist/quill.snow.css' as quill; + // Bootstrap must be after _colors since we define the colors there @use '../node_modules/bootstrap/scss/bootstrap'; diff --git a/UI/Web/tsconfig.json b/UI/Web/tsconfig.json index cf1f7a8c6..af93f05ea 100644 --- a/UI/Web/tsconfig.json +++ b/UI/Web/tsconfig.json @@ -14,7 +14,7 @@ "resolveJsonModule": true, "declaration": false, "experimentalDecorators": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "target": "ES2022", "module": "ES2022", @@ -22,7 +22,7 @@ "lib": [ "ES2022", "dom" - ], + ] }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false,