diff --git a/API/API.csproj b/API/API.csproj index f6be7c9ae..11b532bdb 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -81,7 +81,6 @@ - diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 2277f438c..beab49d95 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -102,16 +102,13 @@ public class AccountController : BaseApiController try { - user.UpdateLastActive(); + await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id); } catch (Exception ex) { _logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName); } - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); - return Ok(await ConstructUserDto(user, roles, false)); } @@ -293,7 +290,7 @@ public class AccountController : BaseApiController // Update LastActive on account try { - user.UpdateLastActive(); + await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id); } catch (Exception ex) { diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs index 5da784dc3..6601087a0 100644 --- a/API/Controllers/AnnotationController.cs +++ b/API/Controllers/AnnotationController.cs @@ -1,14 +1,20 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Encodings.Web; using System.Threading.Tasks; using API.Data; +using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Reader; using API.Entities; using API.Extensions; using API.Helpers; using API.Services; using API.SignalR; +using HtmlAgilityPack; using Kavita.Common; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -19,18 +25,35 @@ public class AnnotationController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly IBookService _bookService; private readonly ILocalizationService _localizationService; private readonly IEventHub _eventHub; + private readonly IAnnotationService _annotationService; public AnnotationController(IUnitOfWork unitOfWork, ILogger logger, - IBookService bookService, ILocalizationService localizationService, IEventHub eventHub) + ILocalizationService localizationService, IEventHub eventHub, IAnnotationService annotationService) { _unitOfWork = unitOfWork; _logger = logger; - _bookService = bookService; _localizationService = localizationService; _eventHub = eventHub; + _annotationService = annotationService; + } + + /// + /// Returns a list of annotations for browsing + /// + /// + /// + /// + [HttpPost("all-filtered")] + public async Task>> GetAnnotationsForBrowse(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + + var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); } /// @@ -41,7 +64,6 @@ public class AnnotationController : BaseApiController [HttpGet("all")] public async Task>> GetAnnotations(int chapterId) { - return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId)); } @@ -77,62 +99,16 @@ public class AnnotationController : BaseApiController { 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)); + return Ok(await _annotationService.CreateAnnotation(User.GetUserId(), dto)); } - catch (Exception ex) + catch (KavitaException 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")); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } } /// - /// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation + /// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation /// /// /// @@ -141,28 +117,12 @@ public class AnnotationController : BaseApiController { 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); - } + return Ok(await _annotationService.UpdateAnnotation(User.GetUserId(), dto)); } - catch (Exception ex) + catch (KavitaException ex) { - _logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber); - return BadRequest(); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } - - return Ok(); } /// @@ -174,10 +134,75 @@ public class AnnotationController : BaseApiController 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")); + if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "annotation-delete")); _unitOfWork.AnnotationRepository.Remove(annotation); await _unitOfWork.CommitAsync(); + return Ok(); } + + /// + /// Removes annotations in bulk. Requires every annotation to be owned by the authenticated user + /// + /// + /// + [HttpPost("bulk-delete")] + public async Task DeleteAnnotationsBulk(IList annotationIds) + { + var userId = User.GetUserId(); + + var annotations = await _unitOfWork.AnnotationRepository.GetAnnotations(annotationIds); + if (annotations.Any(a => a.AppUserId != userId)) + { + return BadRequest(); + } + + _unitOfWork.AnnotationRepository.Remove(annotations); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + /// + /// Exports annotations for the given users + /// + /// + [HttpPost("export-filter")] + public async Task ExportAnnotationsFilter(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + + var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); + var annotations = list.Select(a => a.Id).ToList(); + + var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations); + if (string.IsNullOrEmpty(json)) return BadRequest(); + + var bytes = Encoding.UTF8.GetBytes(json); + var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}_filtered"); + return File(bytes, "application/json", fileName + ".json"); + } + + /// + /// Exports Annotations for the User + /// + /// Export annotations with the given ids + /// + [HttpPost("export")] + public async Task ExportAnnotations(IList? annotations = null) + { + var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations); + if (string.IsNullOrEmpty(json)) return BadRequest(); + + var bytes = Encoding.UTF8.GetBytes(json); + + var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}"); + if (annotations != null) + { + fileName += "_user_selection"; + } + + return File(bytes, "application/json", fileName + ".json"); + } } diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index 30ed68771..a8e351467 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -50,7 +50,7 @@ public class LicenseController( } /// - /// Has any license registered with the instance. Does not check Kavita+ API + /// Has any license registered with the instance. Does not validate against Kavita+ API /// /// [Authorize("RequireAdminRole")] @@ -117,6 +117,16 @@ public class LicenseController( return BadRequest(localizationService.Translate(User.GetUserId(), "unable-to-reset-k+")); } + /// + /// Resend the welcome email to the user + /// + /// + [HttpPost("resend-license")] + public async Task> ResendWelcomeEmail() + { + return Ok(await licenseService.ResendWelcomeEmail()); + } + /// /// Updates server license /// diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index e9b930f07..267dbb7af 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -1,158 +1,57 @@ using System; -using System.Collections.Generic; -using System.Globalization; using System.IO; -using System.Linq; -using System.Net; using System.Threading.Tasks; -using System.Xml; using System.Xml.Serialization; -using API.Comparators; using API.Data; -using API.Data.Repositories; -using API.DTOs; -using API.DTOs.Collection; -using API.DTOs.CollectionTags; -using API.DTOs.Filtering; -using API.DTOs.Filtering.v2; using API.DTOs.OPDS; -using API.DTOs.Person; +using API.DTOs.OPDS.Requests; using API.DTOs.Progress; -using API.DTOs.ReadingLists; -using API.DTOs.Search; -using API.Entities; using API.Entities.Enums; +using API.Exceptions; using API.Extensions; -using API.Helpers; +using API.Middleware; using API.Services; -using API.Services.Tasks.Scanner.Parser; -using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; using MimeTypes; namespace API.Controllers; - #nullable enable -/** - * 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 -{ - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - int userId; - try - { - if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey) - { - context.Result = new BadRequestResult(); - return; - } - - userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - if (userId == null || userId == 0) - { - context.Result = new UnauthorizedResult(); - return; - } - - var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.EnableOpds) - { - context.Result = new ContentResult - { - Content = await localizationService.Translate(userId, "opds-disabled"), - ContentType = "text/plain", - StatusCode = (int)HttpStatusCode.BadRequest, - }; - return; - } - } - catch (Exception ex) - { - logger.LogError(ex, "failed to handle OPDS request"); - context.Result = new BadRequestResult(); - return; - } - - context.HttpContext.Items.Add(OpdsController.UserId, userId); - await next(); - } - -} [AllowAnonymous] [ServiceFilter(typeof(OpdsActionFilterAttribute))] +[ServiceFilter(typeof(OpdsActiveUserMiddlewareAttribute))] public class OpdsController : BaseApiController { - private readonly ILogger _logger; + private readonly IOpdsService _opdsService; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; private readonly IReaderService _readerService; - private readonly ISeriesService _seriesService; private readonly IAccountService _accountService; private readonly ILocalizationService _localizationService; - private readonly IMapper _mapper; - - - private readonly XmlSerializer _xmlSerializer; private readonly XmlSerializer _xmlOpenSearchSerializer; - private readonly FilterDto _filterDto = new() - { - Formats = [], - Character = [], - Colorist = [], - Editor = [], - Genres = [], - Inker = [], - Languages = [], - Letterer = [], - Penciller = [], - Libraries = [], - Publisher = [], - Rating = 0, - Tags = [], - Translators = [], - Writers = [], - AgeRating = [], - CollectionTags = [], - CoverArtist = [], - ReadStatus = new ReadStatus(), - SortOptions = null, - PublicationStatus = [] - }; - private readonly FilterV2Dto _filterV2Dto = new(); - private const int PageSize = 20; public const string UserId = nameof(UserId); public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, - IReaderService readerService, ISeriesService seriesService, - IAccountService accountService, ILocalizationService localizationService, - IMapper mapper, ILogger logger) + IReaderService readerService, IAccountService accountService, + ILocalizationService localizationService, IOpdsService opdsService) { _unitOfWork = unitOfWork; _downloadService = downloadService; _directoryService = directoryService; _cacheService = cacheService; _readerService = readerService; - _seriesService = seriesService; _accountService = accountService; _localizationService = localizationService; - _mapper = mapper; - _logger = logger; + _opdsService = opdsService; - _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); } @@ -161,177 +60,28 @@ public class OpdsController : BaseApiController return (int) HttpContext.Items[UserId]!; } + /// + /// Returns the Catalogue for Kavita's OPDS Service + /// + /// + /// [HttpPost("{apiKey}")] [HttpGet("{apiKey}")] [Produces("application/xml")] public async Task Get(string apiKey) { - var userId = GetUserIdFromContext(); - var (_, prefix) = await GetPrefix(); + var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix); - SetFeedId(feed, "root"); - - // Get the user's customized dashboard - var streams = await _unitOfWork.UserRepository.GetDashboardStreams(userId, true); - foreach (var stream in streams) + var feed = await _opdsService.GetCatalogue(new OpdsCatalogueRequest { - switch (stream.StreamType) - { - case DashboardStreamType.OnDeck: - feed.Entries.Add(new FeedEntry() - { - Id = "onDeck", - Title = await _localizationService.Translate(userId, "on-deck"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-on-deck") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"), - ] - }); - break; - case DashboardStreamType.NewlyAdded: - feed.Entries.Add(new FeedEntry() - { - Id = "recentlyAdded", - Title = await _localizationService.Translate(userId, "recently-added"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-recently-added") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"), - ] - }); - break; - case DashboardStreamType.RecentlyUpdated: - feed.Entries.Add(new FeedEntry() - { - Id = "recentlyUpdated", - Title = await _localizationService.Translate(userId, "recently-updated"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-recently-updated") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-updated"), - ] - }); - break; - case DashboardStreamType.MoreInGenre: - var randomGenre = await _unitOfWork.GenreRepository.GetRandomGenre(); - if (randomGenre == null) break; - - feed.Entries.Add(new FeedEntry() - { - Id = "moreInGenre", - Title = await _localizationService.Translate(userId, "more-in-genre", randomGenre.Title), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-more-in-genre", randomGenre.Title) - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/more-in-genre?genreId={randomGenre.Id}"), - ] - }); - break; - case DashboardStreamType.SmartFilter: - - feed.Entries.Add(new FeedEntry() - { - Id = "smartFilter-" + stream.Id, - Title = stream.Name, - Content = new FeedEntryContent() - { - Text = stream.Name - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/smart-filters/{stream.SmartFilterId}/") - ] - }); - break; - } - } - - feed.Entries.Add(new FeedEntry() - { - Id = "readingList", - Title = await _localizationService.Translate(userId, "reading-lists"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-reading-lists") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"), - ] - }); - feed.Entries.Add(new FeedEntry() - { - Id = "wantToRead", - Title = await _localizationService.Translate(userId, "want-to-read"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-want-to-read") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"), - ] - }); - feed.Entries.Add(new FeedEntry() - { - Id = "allLibraries", - Title = await _localizationService.Translate(userId, "libraries"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-libraries") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"), - ] - }); - feed.Entries.Add(new FeedEntry() - { - Id = "allCollections", - Title = await _localizationService.Translate(userId, "collections"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-collections") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"), - ] + ApiKey = apiKey, + Prefix = prefix, + BaseUrl = baseUrl, + UserId = GetUserIdFromContext() }); - if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any()) - { - feed.Entries.Add(new FeedEntry() - { - Id = "allSmartFilters", - Title = await _localizationService.Translate(userId, "smart-filters"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-smart-filters") - }, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"), - ] - }); - } - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); } private async Task> GetPrefix() @@ -348,394 +98,350 @@ public class OpdsController : BaseApiController } /// - /// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records. + /// Get the User's Smart Filter series - Supports Pagination /// /// [HttpGet("{apiKey}/smart-filters/{filterId}")] [Produces("application/xml")] - public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0) + public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 1) { var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); - var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); - if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist")); - var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix); - SetFeedId(feed, "smartFilters-" + filter.Id); - - var decodedFilter = SmartFilterHelper.Decode(filter.Filter); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), - decodedFilter); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); - - foreach (var seriesDto in series) + var feed = await _opdsService.GetSeriesFromSmartFilter(new OpdsItemsFromEntityIdRequest() { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); - } + ApiKey = apiKey, + Prefix = prefix, + BaseUrl = baseUrl, + EntityId = filterId, + UserId = userId, + PageNumber = pageNumber + }); - AddPagination(feed, series, $"{prefix}{apiKey}/smart-filters/{filterId}/"); - return CreateXmlResult(SerializeXml(feed)); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); } + /// + /// Get the User's Smart Filters (Dashboard Context) - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/smart-filters")] [Produces("application/xml")] - public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (_, prefix) = await GetPrefix(); - - var filters = await _unitOfWork.AppUserSmartFilterRepository.GetPagedDtosByUserIdAsync(userId, GetUserParams(pageNumber)); - var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix); - SetFeedId(feed, "smartFilters"); - - foreach (var filter in filters) + try { - feed.Entries.Add(new FeedEntry() - { - Id = filter.Id.ToString(), - Title = filter.Name, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/smart-filters/{filter.Id}") - ] - }); - } + var userId = GetUserIdFromContext(); + var (baseUrl, prefix) = await GetPrefix(); - AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters"); - return CreateXmlResult(SerializeXml(feed)); + var feed = await _opdsService.GetSmartFilters(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = userId, + ApiKey = apiKey, + PageNumber = pageNumber + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Get the User's Libraries - No Pagination Support + /// + /// + /// + /// [HttpGet("{apiKey}/libraries")] [Produces("application/xml")] - public async Task GetLibraries(string apiKey) + public async Task GetLibraries(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix); - SetFeedId(feed, "libraries"); - - // Ensure libraries follow SideNav order - var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId); - foreach (var library in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library) - .Select(sideNavStream => sideNavStream.Library)) + try { - feed.Entries.Add(new FeedEntry() - { - Id = library!.Id.ToString(), - Title = library.Name!, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/libraries/{library.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}") - ] - }); - } + var (baseUrl, prefix) = await GetPrefix(); - return CreateXmlResult(SerializeXml(feed)); + var feed = await _opdsService.GetLibraries(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Get the User's Want to Read list - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/want-to-read")] [Produces("application/xml")] - public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var wantToReadSeries = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(userId, GetUserParams(pageNumber), _filterV2Dto); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(wantToReadSeries.Select(s => s.Id)); + try + { + var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(await _localizationService.Translate(userId, "want-to-read"), $"{apiKey}/want-to-read", apiKey, prefix); - SetFeedId(feed, $"want-to-read"); - AddPagination(feed, wantToReadSeries, $"{prefix}{apiKey}/want-to-read"); + var feed = await _opdsService.GetWantToRead(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber + }); - feed.Entries.AddRange(wantToReadSeries.Select(seriesDto => - CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl))); - - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Get all Collections - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/collections")] [Produces("application/xml")] - public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) return Unauthorized(); - - var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosPagedAsync(user.Id, GetUserParams(pageNumber), true); - - var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix); - SetFeedId(feed, "collections"); - - - feed.Entries.AddRange(tags.Select(tag => new FeedEntry() + try { - Id = tag.Id.ToString(), - Title = tag.Title, - Summary = tag.Summary, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/collections/{tag.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") - ] - })); + var (baseUrl, prefix) = await GetPrefix(); - AddPagination(feed, tags, $"{prefix}{apiKey}/collections"); - return CreateXmlResult(SerializeXml(feed)); + var feed = await _opdsService.GetCollections(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } - + /// + /// Get Series for a given Collection - Supports Pagination + /// + /// + /// + /// + /// [HttpGet("{apiKey}/collections/{collectionId}")] [Produces("application/xml")] - public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) return Unauthorized(); - - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId); - if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted)) + try { - return BadRequest("Collection does not exist or you don't have access"); + var (baseUrl, prefix) = await GetPrefix(); + + var feed = await _opdsService.GetSeriesFromCollection(new OpdsItemsFromEntityIdRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + EntityId = collectionId + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); } - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, GetUserParams(pageNumber)); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); - - var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey, prefix); - SetFeedId(feed, $"collections-{collectionId}"); - AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}"); - - foreach (var seriesDto in series) + catch (OpdsException ex) { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + return BadRequest(ex.Message); } - - - return CreateXmlResult(SerializeXml(feed)); } + /// + /// Get a User's Reading Lists - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/reading-list")] [Produces("application/xml")] - public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - - var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, - true, GetUserParams(pageNumber), false); - - - var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix); - SetFeedId(feed, "reading-list"); - AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/"); - - foreach (var readingListDto in readingLists) + try { - feed.Entries.Add(new FeedEntry() + var (baseUrl, prefix) = await GetPrefix(); + + var feed = await _opdsService.GetReadingLists(new OpdsPaginatedCatalogueRequest() { - Id = readingListDto.Id.ToString(), - Title = readingListDto.Title, - Summary = readingListDto.Summary, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}") - ] + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); } - - - return CreateXmlResult(SerializeXml(feed)); } - + /// + /// Returns individual items (chapters) from Reading List by ID - Supports Pagination + /// + /// + /// + /// + /// [HttpGet("{apiKey}/reading-list/{readingListId}")] [Produces("application/xml")] - public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + try { - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - } + var (baseUrl, prefix) = await GetPrefix(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) - { - return Unauthorized(); - } - - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id); - if (readingList == null) - { - return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); - } - - var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); - SetFeedId(feed, $"reading-list-{readingListId}"); - - - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(pageNumber))).ToList(); - var totalItems = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).Count(); - - - // Check if there is reading progress or not, if so, inject a "continue-reading" item - var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0); - if (firstReadReadingListItem != null) - { - await AddContinueReadingPoint(apiKey, firstReadReadingListItem, userId, feed, prefix, baseUrl); - } - - foreach (var item in items) - { - var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId); - - // If there is only one file underneath, add a direct acquisition link, otherwise add a subsection - if (chapterDto is {Files.Count: 1}) + var feed = await _opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest() { - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId); - feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId, - chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl)); - } - else - { - feed.Entries.Add( - CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", - item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); - } + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + EntityId = readingListId + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); } - - AddPagination(feed, pageNumber, totalItems, UserParams.Default.PageSize, $"{prefix}{apiKey}/reading-list/{readingListId}/"); - return CreateXmlResult(SerializeXml(feed)); - } - - private async Task AddContinueReadingPoint(string apiKey, int seriesId, ChapterDto chapterDto, int userId, - Feed feed, string prefix, string baseUrl) - { - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - if (chapterDto is {Files.Count: 1}) + catch (OpdsException ex) { - feed.Entries.Add(await CreateContinueReadingFromFile(userId, seriesId, chapterDto.VolumeId, chapterDto.Id, - chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl)); + return BadRequest(ex.Message); } } - private async Task AddContinueReadingPoint(string apiKey, ReadingListItemDto firstReadReadingListItem, int userId, - Feed feed, string prefix, string baseUrl) - { - var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, userId); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, userId); - if (chapterDto is {Files.Count: 1}) - { - feed.Entries.Add(await CreateContinueReadingFromFile(userId, firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId, firstReadReadingListItem.ChapterId, - chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl)); - } - } + /// + /// Returns Series from the Library - Supports Pagination + /// + /// + /// + /// + /// [HttpGet("{apiKey}/libraries/{libraryId}")] [Produces("application/xml")] - public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0) + public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var library = - (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => - l.Id == libraryId); - if (library == null) + try { - return BadRequest(await _localizationService.Translate(userId, "no-library-access")); + var (baseUrl, prefix) = await GetPrefix(); + + var feed = await _opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + EntityId = libraryId + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); } - - var filter = new FilterV2Dto + catch (OpdsException ex) { - Statements = [ - new () - { - Comparison = FilterComparison.Equal, - Field = FilterField.Libraries, - Value = libraryId + string.Empty - } - ] - }; - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); - - var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix); - SetFeedId(feed, $"library-{library.Name}"); - AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}"); - - feed.Entries.AddRange(series.Select(seriesDto => - CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl))); - - return CreateXmlResult(SerializeXml(feed)); + return BadRequest(ex.Message); + } } + /// + /// Returns Recently Added (Dashboard Feed) - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/recently-added")] [Produces("application/xml")] public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); - - var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{apiKey}/recently-added", apiKey, prefix); - SetFeedId(feed, "recently-added"); - AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added"); - - foreach (var seriesDto in recentlyAdded) + try { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); - } + var (baseUrl, prefix) = await GetPrefix(); + var feed = await _opdsService.GetRecentlyAdded(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + }); - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Returns More In a Genre (Dashboard Feed) - Supports Pagination + /// + /// + /// + /// + /// [HttpGet("{apiKey}/more-in-genre")] [Produces("application/xml")] public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId); - if (genre == null) return BadRequest(await _localizationService.Translate(userId, "genre-doesnt-exist")); - var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber)); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id)); - - var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{apiKey}/more-in-genre", apiKey, prefix); - SetFeedId(feed, "more-in-genre"); - AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/more-in-genre"); - - foreach (var seriesDto in seriesDtos) + try { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); - } + var (baseUrl, prefix) = await GetPrefix(); + var feed = await _opdsService.GetMoreInGenre(new OpdsItemsFromEntityIdRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + EntityId = genreId + }); - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } /// - /// Returns recently updated series. While pagination is avaible, total amount of pages is not due to implementation - /// details + /// Get the Recently Updated Series (Dashboard) - Pagination available, total pages will not be filled due to underlying implementation /// /// /// @@ -744,63 +450,54 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + try { - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - } - - var userParams = new UserParams - { - PageNumber = pageNumber, - PageSize = PageSize, - }; - var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, userParams)).ToList(); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId)); - - var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix); - SetFeedId(feed, "recently-updated"); - - foreach (var groupedSeries in seriesDtos) - { - var seriesDto = new SeriesDto() + var (baseUrl, prefix) = await GetPrefix(); + var feed = await _opdsService.GetRecentlyUpdated(new OpdsPaginatedCatalogueRequest() { - Name = $"{groupedSeries.SeriesName} ({groupedSeries.Count})", - Id = groupedSeries.SeriesId, - Format = groupedSeries.Format, - LibraryId = groupedSeries.LibraryId, - }; - var metadata = seriesMetadatas.First(s => s.SeriesId == seriesDto.Id); - feed.Entries.Add(CreateSeries(seriesDto, metadata, apiKey, prefix, baseUrl)); - } + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + }); - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Get the On Deck (Dashboard) - Supports Pagination + /// + /// + /// + /// [HttpGet("{apiKey}/on-deck")] [Produces("application/xml")] public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - - var userParams = GetUserParams(pageNumber); - var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id)); - - Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - - var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{apiKey}/on-deck", apiKey, prefix); - SetFeedId(feed, "on-deck"); - AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck"); - - foreach (var seriesDto in pagedList) + try { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); - } + var (baseUrl, prefix) = await GetPrefix(); + var feed = await _opdsService.GetOnDeck(new OpdsPaginatedCatalogueRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + PageNumber = pageNumber, + }); - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } /// @@ -813,71 +510,24 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - - if (string.IsNullOrEmpty(query)) + try { - return BadRequest(await _localizationService.Translate(userId, "query-required")); - } - query = query.Replace(@"%", string.Empty); - - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); - - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, - libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false); - - var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix); - SetFeedId(feed, "search-series"); - foreach (var seriesDto in searchResults.Series) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl)); - } - - foreach (var collection in searchResults.Collections) - { - feed.Entries.Add(new FeedEntry() + var (baseUrl, prefix) = await GetPrefix(); + var feed = await _opdsService.Search(new OpdsSearchRequest() { - Id = collection.Id.ToString(), - Title = collection.Title, - Summary = collection.Summary, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/collections/{collection.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}") - ] + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + Query = query, }); - } - foreach (var readingListDto in searchResults.ReadingLists) + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) { - feed.Entries.Add(new FeedEntry() - { - Id = readingListDto.Id.ToString(), - Title = readingListDto.Title, - Summary = readingListDto.Summary, - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), - ] - }); + return BadRequest(ex.Message); } - - feed.Total = feed.Entries.Count; - return CreateXmlResult(SerializeXml(feed)); - } - - private static void SetFeedId(Feed feed, string id) - { - feed.Id = id; } [HttpGet("{apiKey}/search")] @@ -886,6 +536,7 @@ public class OpdsController : BaseApiController { var userId = GetUserIdFromContext(); var (_, prefix) = await GetPrefix(); + var feed = new OpenSearchDescription() { ShortName = await _localizationService.Translate(userId, "search"), @@ -903,164 +554,107 @@ public class OpdsController : BaseApiController return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8")); } + /// + /// Returns the items within a Series (Series Detail) + /// + /// + /// + /// [HttpGet("{apiKey}/series/{seriesId}")] [Produces("application/xml")] - public async Task GetSeries(string apiKey, int seriesId) + public async Task GetSeriesDetail(string apiKey, int seriesId) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - - var feed = CreateFeed(series!.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey, prefix); - SetFeedId(feed, $"series-{series.Id}"); - feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); - - - // Check if there is reading progress or not, if so, inject a "continue-reading" item - var anyUserProgress = await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); - if (anyUserProgress) + try { - var chapterDto = await _readerService.GetContinuePoint(seriesId, userId); - await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl); - } + var (baseUrl, prefix) = await GetPrefix(); - - var chapterDict = new Dictionary(); - var fileDict = new Dictionary(); - - var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); - foreach (var volume in seriesDetail.Volumes) - { - var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChapterDtosAsync(volume.Id, userId); - - foreach (var chapterDto in chaptersForVolume) + var feed = await _opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest() { - var chapterId = chapterDto.Id; - if (!chapterDict.TryAdd(chapterId, 0)) continue; + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + EntityId = seriesId + }); - foreach (var mangaFile in chapterDto.Files) - { - // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception - if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; - - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, - chapterDto, apiKey, prefix, baseUrl)); - } - } + return CreateXmlResult(_opdsService.SerializeXml(feed)); } - - var chapters = seriesDetail.StorylineChapters; - if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any()) + catch (OpdsException ex) { - chapters = seriesDetail.Chapters; + return BadRequest(ex.Message); } - - foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id))) - { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); - var chapterDto = _mapper.Map(chapter); - foreach (var mangaFile in files) - { - // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception - if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series, - chapterDto, apiKey, prefix, baseUrl)); - } - } - - foreach (var special in seriesDetail.Specials) - { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id); - var chapterDto = _mapper.Map(special); - foreach (var mangaFile in files) - { - // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception - if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; - - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series, - chapterDto, apiKey, prefix, baseUrl)); - } - } - - return CreateXmlResult(SerializeXml(feed)); } + /// + /// Returns items for a given Volume + /// + /// + /// + /// + /// [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")] [Produces("application/xml")] public async Task GetVolume(string apiKey, int seriesId, int volumeId) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - if (series == null) + try { - return NotFound(); - } + var (baseUrl, prefix) = await GetPrefix(); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); - if (volume == null) - { - return NotFound(); - } - - var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}", - $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); - SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}"); - - var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId); - - // Check if there is reading progress or not, if so, inject a "continue-reading" item - var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0); - if (firstChapterWithProgress != null) - { - var chapterDto = await _readerService.GetContinuePoint(seriesId, userId); - await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl); - } - - foreach (var chapterDto in chapterDtos) - { - foreach (var mangaFile in chapterDto.Files) + var feed = await _opdsService.GetItemsFromVolume(new OpdsItemsFromCompoundEntityIdsRequest() { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl)); - } - } + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + SeriesId = seriesId, + VolumeId = volumeId + }); - return CreateXmlResult(SerializeXml(feed)); + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Gets items for a given Chapter + /// + /// + /// + /// + /// + /// [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")] [Produces("application/xml")] public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) { - var userId = GetUserIdFromContext(); - var (baseUrl, prefix) = await GetPrefix(); - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist")); - - var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId); - - if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); - - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - - var chapterName = await _seriesService.FormatChapterName(userId, libraryType); - var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}", - $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); - SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files"); - - foreach (var mangaFile in chapter.Files) + try { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); - } + var (baseUrl, prefix) = await GetPrefix(); - return CreateXmlResult(SerializeXml(feed)); + var feed = await _opdsService.GetItemsFromChapter(new OpdsItemsFromCompoundEntityIdsRequest() + { + BaseUrl = baseUrl, + Prefix = prefix, + UserId = GetUserIdFromContext(), + ApiKey = apiKey, + SeriesId = seriesId, + VolumeId = volumeId, + ChapterId = chapterId + }); + + return CreateXmlResult(_opdsService.SerializeXml(feed)); + } + catch (OpdsException ex) + { + return BadRequest(ex.Message); + } } /// - /// Downloads a file + /// Downloads a file (user must have download permission) /// /// User's API Key /// @@ -1075,7 +669,7 @@ public class OpdsController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (!await _accountService.HasDownloadPermission(user)) { - return Forbid("User does not have download permissions"); + return Forbid(await _localizationService.Translate(userId, "download-not-allowed")); } var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); @@ -1093,246 +687,6 @@ public class OpdsController : BaseApiController }; } - private static void AddPagination(Feed feed, PagedList list, string href) - { - var url = href; - if (href.Contains('?')) - { - url += "&"; - } - else - { - url += "?"; - } - - var pageNumber = Math.Max(list.CurrentPage, 1); - - if (pageNumber > 1) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); - } - - if (pageNumber + 1 <= list.TotalPages) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); - } - - // Update self to point to current page - var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); - if (selfLink != null) - { - selfLink.Href = url + "pageNumber=" + pageNumber; - } - - - feed.Total = list.TotalCount; - feed.ItemsPerPage = list.PageSize; - feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1; - } - - private static void AddPagination(Feed feed, int currentPage, int totalItems, int pageSize, string href) - { - var url = href; - if (href.Contains('?')) - { - url += "&"; - } - else - { - url += "?"; - } - - var pageNumber = Math.Max(currentPage, 1); - var totalPages = totalItems / pageSize; - - if (pageNumber > 1) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); - } - - if (pageNumber + 1 <= totalPages) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); - } - - // Update self to point to current page - var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); - if (selfLink != null) - { - selfLink.Href = url + "pageNumber=" + pageNumber; - } - - - feed.Total = totalItems; - feed.ItemsPerPage = pageSize; - feed.StartIndex = (Math.Max(currentPage - 1, 0) * pageSize) + 1; - } - - - private static FeedEntry CreateSeries(SeriesDto seriesDto, SeriesMetadataDto metadata, string apiKey, string prefix, string baseUrl) - { - return new FeedEntry() - { - Id = seriesDto.Id.ToString(), - Title = $"{seriesDto.Name}", - Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary) - ? string.Empty - : $" Summary: {metadata.Summary}"), - Authors = metadata.Writers.Select(CreateAuthor).ToList(), - Categories = metadata.Genres.Select(g => new FeedCategory() - { - Label = g.Title, - Term = string.Empty - }).ToList(), - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/series/{seriesDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") - ] - }; - } - - private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey, string prefix, string baseUrl) - { - return new FeedEntry() - { - Id = searchResultDto.SeriesId.ToString(), - Title = $"{searchResultDto.Name}", - Summary = $"Format: {searchResultDto.Format}", - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}") - ] - }; - } - - private static FeedAuthor CreateAuthor(PersonDto person) - { - return new FeedAuthor() - { - Name = person.Name, - Uri = "http://opds-spec.org/author/" + person.Id - }; - } - - private static FeedEntry CreateChapter(string apiKey, string title, string? summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl) - { - return new FeedEntry() - { - Id = chapterId.ToString(), - Title = title, - Summary = summary ?? string.Empty, - - Links = - [ - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}") - ] - }; - } - - private async Task CreateContinueReadingFromFile(int userId, int seriesId, int volumeId, int chapterId, - MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) - { - var entry = await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, - apiKey, prefix, baseUrl); - - entry.Title = await _localizationService.Translate(userId, "opds-continue-reading-title", entry.Title); - - return entry; - } - - private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, - MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) - { - var fileSize = - mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : - DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List() - {mangaFile.FilePath})); - var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); - var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath)); - var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId); - - - var title = $"{series.Name}"; - - if (volume!.Chapters.Count == 1 && !volume.IsSpecial()) - { - var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); - SeriesService.RenameVolumeName(volume, libraryType, volumeLabel); - - if (!volume.IsLooseLeaf()) - { - title += $" - {volume.Name}"; - } - } - else if (!volume.IsLooseLeaf() && !volume.IsSpecial()) - { - title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; - } - else - { - title = $"{series.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; - } - - // Chunky requires a file at the end. Our API ignores this - var accLink = CreateLink(FeedLinkRelation.Acquisition, fileType, - $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}", - filename); - - accLink.TotalPages = chapter.Pages; - - var entry = new FeedEntry() - { - Id = mangaFile.Id.ToString(), - Title = title, - Extent = fileSize, - Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary) - ? string.Empty - : $" Summary: {chapter.Summary}"), - Format = mangaFile.Format.ToString(), - Links = - [ - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, - $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, - $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - // We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly - accLink - ], - Content = new FeedEntryContent() - { - Text = fileType, - Type = "text" - }, - Authors = chapter.Writers.Select(CreateAuthor).ToList() - }; - - var canPageStream = mangaFile.Extension != ".epub"; - if (canPageStream) - { - entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix)); - } - - // Patch in reading status on the item (as OPDS is seriously lacking) - entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}"; - - return entry; - } /// /// This returns a streamed image following OPDS-PS v1.2 @@ -1367,9 +721,7 @@ public class OpdsController : BaseApiController Response.AddCacheHeader(content); // Save progress for the user (except Panels, they will use a direct connection) - var userAgent = Request.Headers["User-Agent"].ToString(); - - + var userAgent = Request.Headers.UserAgent.ToString(); if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress) { @@ -1418,147 +770,4 @@ public class OpdsController : BaseApiController return File(content, MimeTypeMap.GetMimeType(format)); } - - /// - /// Gets the user from the API key - /// - /// - private async Task GetUser(string apiKey) - { - try - { - return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - } - catch - { - /* Do nothing */ - } - throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); - } - - private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix) - { - var userId = await GetUser(apiKey); - var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); - - // NOTE: Type could be wrong, there is nothing I can do in the spec - var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", - $"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); - link.TotalPages = mangaFile.Pages; - link.IsPageStream = true; - - if (progress != null) - { - link.LastRead = progress.PageNum; - link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601 - } - - return link; - } - - private static FeedLink CreateLink(string rel, string type, string href, string? title = null) - { - return new FeedLink() - { - Rel = rel, - Href = href, - Type = type, - Title = string.IsNullOrEmpty(title) ? string.Empty : title - }; - } - - private static Feed CreateFeed(string title, string href, string apiKey, string prefix) - { - var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ? - FeedLinkType.AtomNavigation : - FeedLinkType.AtomAcquisition, prefix + href); - - return new Feed() - { - Title = title, - Icon = $"{prefix}{apiKey}/favicon", - Links = - [ - link, - CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"), - CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search") - ], - }; - } - - private string SerializeXml(Feed? feed) - { - if (feed == null) return string.Empty; - - // Remove invalid XML characters from the feed object - SanitizeFeed(feed); - - using var sm = new StringWriter(); - _xmlSerializer.Serialize(sm, feed); - - var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds - - return ret; - } - - // Recursively sanitize all string properties in the object - private static void SanitizeFeed(object? obj) - { - if (obj == null) return; - - var properties = obj.GetType().GetProperties(); - foreach (var property in properties) - { - // Skip properties that require an index (e.g., indexed collections) - if (property.GetIndexParameters().Length > 0) - continue; - - if (property.PropertyType == typeof(string) && property.CanWrite) - { - var value = (string?)property.GetValue(obj); - if (!string.IsNullOrEmpty(value)) - { - property.SetValue(obj, RemoveInvalidXmlChars(value)); - } - } - else if (property.PropertyType.IsClass) // Handle nested objects - { - var nestedObject = property.GetValue(obj); - if (nestedObject != null) - SanitizeFeed(nestedObject); - } - } - } - - private static UserParams GetUserParams(int pageNumber) - { - return new UserParams() - { - PageNumber = pageNumber, - PageSize = PageSize - }; - } - - private static string RemoveInvalidXmlChars(string input) - { - return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); - } - - private static string GetReadingProgressIcon(int pagesRead, int totalPages) - { - if (pagesRead == 0) return "⭘"; - - var percentageRead = (double)pagesRead / totalPages; - - return percentageRead switch - { - // 100% - >= 1.0 => "⬤", - // > 50% and < 100% - > 0.5 => "◕", - // > 25% and <= 50% - > 0.25 => "◑", - _ => "◔" - }; - } } diff --git a/API/DTOs/Annotations/FullAnnotationDto.cs b/API/DTOs/Annotations/FullAnnotationDto.cs new file mode 100644 index 000000000..17dbe0579 --- /dev/null +++ b/API/DTOs/Annotations/FullAnnotationDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json.Serialization; + +namespace API.DTOs.Annotations; + +public sealed record FullAnnotationDto +{ + public int Id { get; set; } + [JsonIgnore] + public int UserId { get; set; } + public string SelectedText { get; set; } + public string? Comment { get; set; } + public string? CommentHtml { get; set; } + public string? CommentPlainText { get; set; } + public string? Context { get; set; } + public string? ChapterTitle { get; set; } + public int PageNumber { get; set; } + public int SelectedSlotIndex { get; set; } + public bool ContainsSpoiler { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + + public int LibraryId { get; set; } + public string LibraryName { get; set; } + public int SeriesId { get; set; } + public string SeriesName { get; set; } + public int VolumeId { get; set; } + public string VolumeName { get; set; } + public int ChapterId { get; set; } +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index 7082ded69..6d8f3ef37 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -37,5 +37,13 @@ public enum SortField /// /// Randomise the order /// - Random = 9 + Random = 9, +} + +public enum AnnotationSortField +{ + Owner = 1, + Created = 2, + LastModified = 3, + Color = 4, } diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index 18f2b17ea..864801e6b 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -17,3 +17,12 @@ public sealed record PersonSortOptions public PersonSortField SortField { get; set; } public bool IsAscending { get; set; } = true; } + +/// +/// All Sorting Options for a query related to Annotation Entity +/// +public sealed record AnnotationSortOptions +{ + public AnnotationSortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 246a92a90..c00c15708 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -56,6 +56,10 @@ public enum FilterField /// Last time User Read /// ReadLast = 32, + /// + /// Total filesize accross all files for all chapters of the series + /// + FileSize = 33, } public enum PersonFilterField @@ -65,3 +69,22 @@ public enum PersonFilterField SeriesCount = 3, ChapterCount = 4, } + +public enum AnnotationFilterField +{ + Owner = 1, + Library = 2, + Spoiler = 3, + /// + /// When used, only returns your own annotations + /// + HighlightSlot = 4, + /// + /// This is the text selected in the book + /// + Selection = 5, + /// + /// This is the text the user wrote + /// + Comment = 6, +} diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index 8c99bd24c..47d87e94c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,5 +1,4 @@ -using API.DTOs.Metadata.Browse.Requests; - + namespace API.DTOs.Filtering.v2; public sealed record FilterStatementDto @@ -15,3 +14,10 @@ public sealed record PersonFilterStatementDto public PersonFilterField Field { get; set; } public string Value { get; set; } } + +public sealed record AnnotationFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public AnnotationFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs new file mode 100644 index 000000000..4a0f08cda --- /dev/null +++ b/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs @@ -0,0 +1,26 @@ +#nullable enable +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; + +namespace API.DTOs.Metadata.Browse.Requests; + +public class BrowseAnnotationFilterDto +{ + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; + public AnnotationSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; +} diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Internal/Feed.cs similarity index 100% rename from API/DTOs/OPDS/Feed.cs rename to API/DTOs/OPDS/Internal/Feed.cs diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/Internal/FeedAuthor.cs similarity index 100% rename from API/DTOs/OPDS/FeedAuthor.cs rename to API/DTOs/OPDS/Internal/FeedAuthor.cs diff --git a/API/DTOs/OPDS/FeedCategory.cs b/API/DTOs/OPDS/Internal/FeedCategory.cs similarity index 100% rename from API/DTOs/OPDS/FeedCategory.cs rename to API/DTOs/OPDS/Internal/FeedCategory.cs diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/Internal/FeedEntry.cs similarity index 100% rename from API/DTOs/OPDS/FeedEntry.cs rename to API/DTOs/OPDS/Internal/FeedEntry.cs diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/Internal/FeedEntryContent.cs similarity index 100% rename from API/DTOs/OPDS/FeedEntryContent.cs rename to API/DTOs/OPDS/Internal/FeedEntryContent.cs diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/Internal/FeedLink.cs similarity index 100% rename from API/DTOs/OPDS/FeedLink.cs rename to API/DTOs/OPDS/Internal/FeedLink.cs diff --git a/API/DTOs/OPDS/FeedLinkRelation.cs b/API/DTOs/OPDS/Internal/FeedLinkRelation.cs similarity index 100% rename from API/DTOs/OPDS/FeedLinkRelation.cs rename to API/DTOs/OPDS/Internal/FeedLinkRelation.cs diff --git a/API/DTOs/OPDS/FeedLinkType.cs b/API/DTOs/OPDS/Internal/FeedLinkType.cs similarity index 100% rename from API/DTOs/OPDS/FeedLinkType.cs rename to API/DTOs/OPDS/Internal/FeedLinkType.cs diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/Internal/OpenSearchDescription.cs similarity index 100% rename from API/DTOs/OPDS/OpenSearchDescription.cs rename to API/DTOs/OPDS/Internal/OpenSearchDescription.cs diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/Internal/SearchLink.cs similarity index 100% rename from API/DTOs/OPDS/SearchLink.cs rename to API/DTOs/OPDS/Internal/SearchLink.cs diff --git a/API/DTOs/OPDS/Requests/IOpdsPagination.cs b/API/DTOs/OPDS/Requests/IOpdsPagination.cs new file mode 100644 index 000000000..7dba9cc8d --- /dev/null +++ b/API/DTOs/OPDS/Requests/IOpdsPagination.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.OPDS.Requests; + +public interface IOpdsPagination +{ + public int PageNumber { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/IOpdsRequest.cs b/API/DTOs/OPDS/Requests/IOpdsRequest.cs new file mode 100644 index 000000000..05e40b420 --- /dev/null +++ b/API/DTOs/OPDS/Requests/IOpdsRequest.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.OPDS.Requests; + +public interface IOpdsRequest +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs b/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs new file mode 100644 index 000000000..b81b66040 --- /dev/null +++ b/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.OPDS.Requests; + + +public sealed record OpdsCatalogueRequest : IOpdsRequest +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs b/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs new file mode 100644 index 000000000..2cfc48c74 --- /dev/null +++ b/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs @@ -0,0 +1,18 @@ +namespace API.DTOs.OPDS.Requests; + +/// +/// A special case for dealing with lower level entities (volume/chapter) which need higher level entity ids +/// +/// Not all variables will always be used. Implementation will use +public sealed record OpdsItemsFromCompoundEntityIdsRequest : IOpdsRequest, IOpdsPagination +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } + public int PageNumber { get; init; } + + public int SeriesId { get; init; } + public int VolumeId { get; init; } + public int ChapterId { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs b/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs new file mode 100644 index 000000000..24740b527 --- /dev/null +++ b/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.OPDS.Requests; + +public sealed record OpdsSearchRequest : IOpdsRequest +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } + public string Query { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs b/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs new file mode 100644 index 000000000..6572c57e9 --- /dev/null +++ b/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs @@ -0,0 +1,14 @@ +namespace API.DTOs.OPDS.Requests; + +/// +/// A generic Catalogue request for a specific Entity +/// +public sealed record OpdsPaginatedCatalogueRequest : IOpdsRequest, IOpdsPagination +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } + + public int PageNumber { get; init; } +} diff --git a/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs b/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs new file mode 100644 index 000000000..ba9420872 --- /dev/null +++ b/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs @@ -0,0 +1,12 @@ +namespace API.DTOs.OPDS.Requests; + +public sealed record OpdsItemsFromEntityIdRequest : IOpdsRequest, IOpdsPagination +{ + public string ApiKey { get; init; } + public string Prefix { get; init; } + public string BaseUrl { get; init; } + public int UserId { get; init; } + + public int EntityId { get; init; } + public int PageNumber { get; init; } = 0; +} diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs index b73fa3baa..729f3ccda 100644 --- a/API/DTOs/Reader/AnnotationDto.cs +++ b/API/DTOs/Reader/AnnotationDto.cs @@ -28,6 +28,10 @@ public sealed record AnnotationDto /// Rich text Comment /// public string? Comment { get; set; } + /// + public string? CommentHtml { get; set; } + /// + public string? CommentPlainText { get; set; } /// /// Title of the TOC Chapter within Epub (not Chapter Entity) /// diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 09e5d0e59..527ec6299 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -9,6 +9,7 @@ namespace API.DTOs; public sealed record UserDto { + public int Id { get; init; } public string Username { get; init; } = null!; public string Email { get; init; } = null!; public IList Roles { get; set; } = []; diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs index bae09ceb4..24dbf1c34 100644 --- a/API/DTOs/UserReadingProfileDto.cs +++ b/API/DTOs/UserReadingProfileDto.cs @@ -111,10 +111,6 @@ public sealed record UserReadingProfileDto [Required] public bool BookReaderImmersiveMode { get; set; } = false; - /// - [Required] - public EpubPageCalculationMethod BookReaderEpubPageCalculationMethod { get; set; } = EpubPageCalculationMethod.Default; - #endregion #region PdfReader diff --git a/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs new file mode 100644 index 000000000..d239e558d --- /dev/null +++ b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs @@ -0,0 +1,3908 @@ +// +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("20250924142016_AddAnnotationsHtmlContent")] + partial class AddAnnotationsHtmlContent + { + /// + 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("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .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.HasIndex("SeriesId"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("BookReaderEpubPageCalculationMethod") + .HasColumnType("INTEGER"); + + 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.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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.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.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/20250924142016_AddAnnotationsHtmlContent.cs b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.cs new file mode 100644 index 000000000..620e37d03 --- /dev/null +++ b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AddAnnotationsHtmlContent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CommentHtml", + table: "AppUserAnnotation", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CommentPlainText", + table: "AppUserAnnotation", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AppUserAnnotation_SeriesId", + table: "AppUserAnnotation", + column: "SeriesId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserAnnotation_Series_SeriesId", + table: "AppUserAnnotation", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserAnnotation_Series_SeriesId", + table: "AppUserAnnotation"); + + migrationBuilder.DropIndex( + name: "IX_AppUserAnnotation_SeriesId", + table: "AppUserAnnotation"); + + migrationBuilder.DropColumn( + name: "CommentHtml", + table: "AppUserAnnotation"); + + migrationBuilder.DropColumn( + name: "CommentPlainText", + table: "AppUserAnnotation"); + } + } +} diff --git a/API/Data/Migrations/20250928181727_RemoveEpubPageCalc.Designer.cs b/API/Data/Migrations/20250928181727_RemoveEpubPageCalc.Designer.cs new file mode 100644 index 000000000..437890075 --- /dev/null +++ b/API/Data/Migrations/20250928181727_RemoveEpubPageCalc.Designer.cs @@ -0,0 +1,3905 @@ +// +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("20250928181727_RemoveEpubPageCalc")] + partial class RemoveEpubPageCalc + { + /// + 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("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .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.HasIndex("SeriesId"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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.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.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/20250928181727_RemoveEpubPageCalc.cs b/API/Data/Migrations/20250928181727_RemoveEpubPageCalc.cs new file mode 100644 index 000000000..8074f1fc5 --- /dev/null +++ b/API/Data/Migrations/20250928181727_RemoveEpubPageCalc.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class RemoveEpubPageCalc : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookReaderEpubPageCalculationMethod", + table: "AppUserReadingProfiles"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BookReaderEpubPageCalculationMethod", + table: "AppUserReadingProfiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 8dd393349..eae94e377 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -180,6 +180,12 @@ namespace API.Data.Migrations b.Property("Comment") .HasColumnType("TEXT"); + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + b.Property("ContainsSpoiler") .HasColumnType("INTEGER"); @@ -231,6 +237,8 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); + b.HasIndex("SeriesId"); + b.ToTable("AppUserAnnotation"); }); @@ -732,9 +740,6 @@ namespace API.Data.Migrations .HasColumnType("TEXT") .HasDefaultValue("#000000"); - b.Property("BookReaderEpubPageCalculationMethod") - .HasColumnType("INTEGER"); - b.Property("BookReaderFontFamily") .HasColumnType("TEXT"); @@ -2983,9 +2988,17 @@ namespace API.Data.Migrations .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.AppUserBookmark", b => diff --git a/API/Data/Repositories/AnnotationRepository.cs b/API/Data/Repositories/AnnotationRepository.cs index 527caa4c7..1918023e9 100644 --- a/API/Data/Repositories/AnnotationRepository.cs +++ b/API/Data/Repositories/AnnotationRepository.cs @@ -1,6 +1,16 @@ -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; +using API.DTOs.Annotations; using API.DTOs.Reader; using API.Entities; +using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -13,8 +23,13 @@ public interface IAnnotationRepository void Attach(AppUserAnnotation annotation); void Update(AppUserAnnotation annotation); void Remove(AppUserAnnotation annotation); + void Remove(IEnumerable annotations); Task GetAnnotationDto(int id); Task GetAnnotation(int id); + Task> GetAnnotations(IList ids); + Task> GetFullAnnotationsByUserIdAsync(int userId); + Task> GetFullAnnotations(int userId, IList annotationIds); + Task> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams); } public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnotationRepository @@ -34,6 +49,11 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota context.AppUserAnnotation.Remove(annotation); } + public void Remove(IEnumerable annotations) + { + context.AppUserAnnotation.RemoveRange(annotations); + } + public async Task GetAnnotationDto(int id) { return await context.AppUserAnnotation @@ -46,4 +66,109 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota return await context.AppUserAnnotation .FirstOrDefaultAsync(a => a.Id == id); } + + public async Task> GetAnnotations(IList ids) + { + return await context.AppUserAnnotation + .Where(a => ids.Contains(a.Id)) + .ToListAsync(); + } + + public async Task> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams) + { + var query = await CreatedFilteredAnnotationQueryable(userId, filter); + return await PagedList.CreateAsync(query, userParams); + } + + private async Task> CreatedFilteredAnnotationQueryable(int userId, BrowseAnnotationFilterDto filter) + { + var allLibrariesCount = await context.Library.CountAsync(); + var userLibs = await context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + + var query = context.AppUserAnnotation.AsNoTracking(); + + query = BuildAnnotationFilterQuery(userId, filter, query); + + var validUsers = await context.AppUserPreferences + .Where(a => a.AppUserId == userId) // TODO: Remove when the below is done + .Where(p => true) // TODO: Filter on sharing annotations preference + .Select(p => p.AppUserId) + .ToListAsync(); + + query = query.Where(a => validUsers.Contains(a.AppUserId)) + .WhereIf(allLibrariesCount != userLibs.Count, + a => seriesIds.Contains(a.SeriesId)); + + var sortedQuery = query.SortBy(filter.SortOptions); + var limitedQuery = filter.LimitTo <= 0 ? sortedQuery : sortedQuery.Take(filter.LimitTo); + + return limitedQuery.ProjectTo(mapper.ConfigurationProvider); + } + + private static IQueryable BuildAnnotationFilterQuery(int userId, BrowseAnnotationFilterDto filter, IQueryable query) + { + if (filter.Statements == null || filter.Statements.Count == 0) return query; + + // Manual intervention for Highlight slots, as they are not user recognisable. But would make sense + // to miss match between users + if (filter.Statements.Any(s => s.Field == AnnotationFilterField.HighlightSlot)) + { + filter.Statements.Add(new AnnotationFilterStatementDto + { + Field = AnnotationFilterField.Owner, + Comparison = FilterComparison.Equal, + Value = $"{userId}", + }); + } + + var queries = filter.Statements + .Select(statement => BuildAnnotationFilterGroup(statement, query)) + .ToList(); + + return filter.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildAnnotationFilterGroup(AnnotationFilterStatementDto statement, IQueryable query) + { + var value = AnnotationFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + AnnotationFilterField.Owner => query.IsOwnedBy(true, statement.Comparison, (IList) value), + AnnotationFilterField.Library => query.IsInLibrary(true, statement.Comparison, (IList) value), + AnnotationFilterField.HighlightSlot => query.IsUsingHighlights(true, statement.Comparison, (IList) value), + AnnotationFilterField.Spoiler => query.Where(a => !(bool) value || !a.ContainsSpoiler), + AnnotationFilterField.Comment => query.HasCommented(true, statement.Comparison, (string) value), + AnnotationFilterField.Selection => query.HasSelected(true, statement.Comparison, (string) value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + }; + } + + public async Task> GetFullAnnotations(int userId, IList annotationIds) + { + return await context.AppUserAnnotation + .AsNoTracking() + .Where(a => annotationIds.Contains(a.Id)) + .Where(a => a.AppUserId == userId) + //.Where(a => a.AppUserId == userId || a.AppUser.UserPreferences.ShareAnnotations) TODO: Filter out annotations for users who don't share them + .SelectFullAnnotation() + .ToListAsync(); + } + + /// + /// This does not track! + /// + /// + /// + public async Task> GetFullAnnotationsByUserIdAsync(int userId) + { + return await context.AppUserAnnotation + .Where(a => a.AppUserId == userId) + .SelectFullAnnotation() + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index c62d286ce..b1c219a93 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1317,7 +1317,8 @@ public class SeriesRepository : ISeriesRepository FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), - _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + FilterField.FileSize => query.HasFileSize(true, statement.Comparison, (long) value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}"), }; } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 29a6cdb32..7732c5edc 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -120,6 +120,7 @@ public interface IUserRepository Task GetAnnotationDtoById(int userId, int annotationId); Task> GetAnnotationDtosBySeries(int userId, int seriesId); + Task UpdateUserAsActive(int userId); } public class UserRepository : IUserRepository @@ -630,6 +631,15 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task UpdateUserAsActive(int userId) + { + await _context.Set() + .Where(u => u.Id == userId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.LastActiveUtc, DateTime.UtcNow) + .SetProperty(u => u.LastActive, DateTime.Now)); + } + public async Task> GetAdminUsersAsync() { diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 167178dac..80ffbbef3 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -38,35 +38,30 @@ public static class Seed 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 } } diff --git a/API/Entities/AppUserAnnotation.cs b/API/Entities/AppUserAnnotation.cs index 434e610fd..960c884ec 100644 --- a/API/Entities/AppUserAnnotation.cs +++ b/API/Entities/AppUserAnnotation.cs @@ -29,6 +29,14 @@ public class AppUserAnnotation : IEntityDate /// public string? Comment { get; set; } /// + /// The annotation in html format, this is generated by the UI (quill) + /// + public string? CommentHtml { get; set; } + /// + /// All html stripped from , this is done by the backend + /// + public string? CommentPlainText { get; set; } + /// /// The number of characters selected /// public int HighlightCount { get; set; } @@ -53,6 +61,7 @@ public class AppUserAnnotation : IEntityDate public required int LibraryId { get; set; } public required int SeriesId { get; set; } + public Series Series { get; set; } public required int VolumeId { get; set; } public required int ChapterId { get; set; } public Chapter Chapter { get; set; } diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs index 7a4de2eb2..18382a614 100644 --- a/API/Entities/AppUserReadingProfile.cs +++ b/API/Entities/AppUserReadingProfile.cs @@ -139,10 +139,6 @@ public class AppUserReadingProfile /// /// Defaults to false public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Book Reader Option: Different calculation modes for the page due to a bleed bug that devs cannot reproduce reliably or fix - /// - public EpubPageCalculationMethod BookReaderEpubPageCalculationMethod { get; set; } = EpubPageCalculationMethod.Default; #endregion #region PdfReader diff --git a/API/Entities/HighlightSlot.cs b/API/Entities/HighlightSlot.cs index b788f8fb2..2f951b290 100644 --- a/API/Entities/HighlightSlot.cs +++ b/API/Entities/HighlightSlot.cs @@ -3,10 +3,6 @@ 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; } } diff --git a/API/Exceptions/OpdsException.cs b/API/Exceptions/OpdsException.cs new file mode 100644 index 000000000..0267628a2 --- /dev/null +++ b/API/Exceptions/OpdsException.cs @@ -0,0 +1,20 @@ +using System; +using API.Controllers; +using API.Services; + +namespace API.Exceptions; + +/// +/// Should be caught in and ONLY used in +/// +public class OpdsException : Exception +{ + public OpdsException() + { } + + public OpdsException(string message) : base(message) + { } + + public OpdsException(string message, Exception inner) + : base(message, inner) { } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index a60053e4e..d8235ed48 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -3,6 +3,7 @@ using API.Constants; using API.Controllers; using API.Data; using API.Helpers; +using API.Middleware; using API.Services; using API.Services.Plus; using API.Services.Store; @@ -60,6 +61,8 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -89,6 +92,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index fbf828104..800e256de 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -12,6 +12,14 @@ namespace API.Extensions; public static class HttpExtensions { + /// + /// Adds pagination headers - Use with + /// + /// + /// + /// + /// + /// public static void AddPaginationHeader(this HttpResponse response, int currentPage, int itemsPerPage, int totalItems, int totalPages) { diff --git a/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs b/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs new file mode 100644 index 000000000..443757fac --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Filtering.v2; +using API.DTOs.Reader; +using API.Entities; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class AnnotationFilter +{ + + public static IQueryable IsOwnedBy(this IQueryable queryable, bool condition, + FilterComparison comparison, IList ownerIds) + { + if (ownerIds.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.AppUserId == ownerIds[0]), + FilterComparison.Contains => queryable.Where(a => ownerIds.Contains(a.AppUserId)), + FilterComparison.NotContains => queryable.Where(a => !ownerIds.Contains(a.AppUserId)), + FilterComparison.NotEqual => queryable.Where(a => a.AppUserId != ownerIds[0]), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + + public static IQueryable IsInLibrary(this IQueryable queryable, bool condition, + FilterComparison comparison, IList libraryIds) + { + if (libraryIds.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.Series.LibraryId == libraryIds[0]), + FilterComparison.Contains => queryable.Where(a => libraryIds.Contains(a.Series.LibraryId)), + FilterComparison.NotContains => queryable.Where(a => !libraryIds.Contains(a.Series.LibraryId)), + FilterComparison.NotEqual => queryable.Where(a => a.Series.LibraryId != libraryIds[0]), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + + public static IQueryable IsUsingHighlights(this IQueryable queryable, bool condition, + FilterComparison comparison, IList highlightSlotIdxs) + { + if (highlightSlotIdxs.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.SelectedSlotIndex== highlightSlotIdxs[0]), + FilterComparison.Contains => queryable.Where(a => highlightSlotIdxs.Contains(a.SelectedSlotIndex)), + FilterComparison.NotContains => queryable.Where(a => !highlightSlotIdxs.Contains(a.SelectedSlotIndex)), + FilterComparison.NotEqual => queryable.Where(a => a.SelectedSlotIndex != highlightSlotIdxs[0]), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + + public static IQueryable HasSelected(this IQueryable queryable, bool condition, + FilterComparison comparison, string value) + { + if (string.IsNullOrEmpty(value) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.SelectedText == value), + FilterComparison.NotEqual => queryable.Where(a => a.SelectedText != value), + FilterComparison.BeginsWith => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"{value}%")), + FilterComparison.EndsWith => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"%{value}")), + FilterComparison.Matches => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"%{value}%")), + FilterComparison.GreaterThan or + FilterComparison.GreaterThanEqual or + FilterComparison.LessThan or + FilterComparison.LessThanEqual or + FilterComparison.Contains or + FilterComparison.MustContains or + FilterComparison.NotContains or + FilterComparison.IsBefore or + FilterComparison.IsAfter or + FilterComparison.IsInLast or + FilterComparison.IsNotInLast or + FilterComparison.IsEmpty => throw new KavitaException($"{comparison} is not applicable for Annotation.SelectedText"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + + public static IQueryable HasCommented(this IQueryable queryable, bool condition, + FilterComparison comparison, string value) + { + if (string.IsNullOrEmpty(value) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.CommentPlainText == value), + FilterComparison.NotEqual => queryable.Where(a => a.CommentPlainText != value), + FilterComparison.BeginsWith => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"{value}%")), + FilterComparison.EndsWith => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"%{value}")), + FilterComparison.Matches => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"%{value}%")), + FilterComparison.GreaterThan or + FilterComparison.GreaterThanEqual or + FilterComparison.LessThan or + FilterComparison.LessThanEqual or + FilterComparison.Contains or + FilterComparison.MustContains or + FilterComparison.NotContains or + FilterComparison.IsBefore or + FilterComparison.IsAfter or + FilterComparison.IsInLast or + FilterComparison.IsNotInLast or + FilterComparison.IsEmpty => throw new KavitaException($"{comparison} is not applicable for Annotation.CommentPlainText"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + +} diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index ad51a4a62..49b8c1de4 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -925,5 +925,21 @@ public static class SeriesFilter } } + public static IQueryable HasFileSize(this IQueryable queryable, bool condition, + FilterComparison comparison, float fileSize) + { + if (fileSize == 0f || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) == fileSize), + FilterComparison.LessThan => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) < fileSize), + FilterComparison.LessThanEqual => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) <= fileSize), + FilterComparison.GreaterThan => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) > fileSize), + FilterComparison.GreaterThanEqual => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) >= fileSize), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"), + }; + } + } diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index ef2af721f..7990056ec 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Annotations; using API.DTOs.Filtering; using API.DTOs.KavitaPlus.Manage; using API.DTOs.Metadata.Browse; @@ -291,10 +292,29 @@ public static class QueryableExtensions PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count), PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count), PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count), - _ => query.OrderBy(p => p.Name) + _ => query.OrderBy(p => p.Name), }; + } + public static IQueryable SortBy(this IQueryable query, AnnotationSortOptions? sort) + { + if (sort == null) + { + return query.OrderBy(a => a.CreatedUtc); + } + return sort.SortField switch + { + AnnotationSortField.Owner when sort.IsAscending => query.OrderBy(a => a.AppUser.UserName), + AnnotationSortField.Owner => query.OrderByDescending(a => a.AppUser.UserName), + AnnotationSortField.Created when sort.IsAscending => query.OrderBy(a => a.CreatedUtc), + AnnotationSortField.Created => query.OrderByDescending(a => a.CreatedUtc), + AnnotationSortField.LastModified when sort.IsAscending => query.OrderBy(a => a.LastModifiedUtc), + AnnotationSortField.LastModified => query.OrderByDescending(a => a.LastModifiedUtc), + AnnotationSortField.Color when sort.IsAscending => query.OrderBy(a => a.SelectedSlotIndex), + AnnotationSortField.Color => query.OrderByDescending(a => a.SelectedSlotIndex), + _ => query.OrderBy(a => a.CreatedUtc), + }; } /// @@ -325,4 +345,35 @@ public static class QueryableExtensions _ => query }; } + + public static IQueryable SelectFullAnnotation(this IQueryable query) + { + return query.Select(a => new FullAnnotationDto + { + Id = a.Id, + UserId = a.AppUserId, + SelectedText = a.SelectedText, + Comment = a.Comment, + CommentHtml = a.CommentHtml, + CommentPlainText = a.CommentPlainText, + Context = a.Context, + ChapterTitle = a.ChapterTitle, + PageNumber = a.PageNumber, + SelectedSlotIndex = a.SelectedSlotIndex, + ContainsSpoiler = a.ContainsSpoiler, + CreatedUtc = a.CreatedUtc, + LastModifiedUtc = a.LastModifiedUtc, + LibraryId = a.LibraryId, + LibraryName = a.Chapter.Volume.Series.Library.Name, + SeriesId = a.SeriesId, + SeriesName = a.Chapter.Volume.Series.Name, + VolumeId = a.VolumeId, + VolumeName = a.Chapter.Volume.Name, + ChapterId = a.ChapterId, + }) + .OrderBy(a => a.SeriesId) + .ThenBy(a => a.VolumeId) + .ThenBy(a => a.ChapterId) + .ThenBy(a => a.PageNumber); + } } diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 78a090e42..f2f69d7f3 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; @@ -6,7 +7,7 @@ using System.Text.RegularExpressions; namespace API.Extensions; #nullable enable -public static class StringExtensions +public static partial class StringExtensions { private static readonly Regex SentenceCaseRegex = new(@"(^[a-z])|\.\s+(.)", RegexOptions.ExplicitCapture | RegexOptions.Compiled, @@ -93,4 +94,51 @@ public static class StringExtensions return string.IsNullOrEmpty(input) ? string.Empty : string.Concat(Enumerable.Repeat(input, n)); } + public static IList ParseIntArray(this string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(int.Parse) + .ToList(); + } + + /// + /// Parses a human-readable file size string (e.g. "1.43 GB") into bytes. + /// + /// The input string like "1.43 GB", "4.2 KB", "512 B" + /// Byte count as long + public static long ParseHumanReadableBytes(this string input) + { + if (string.IsNullOrWhiteSpace(input)) + throw new ArgumentException("Input cannot be null or empty.", nameof(input)); + + var match = HumanReadableBytesRegex().Match(input); + if (!match.Success) + throw new FormatException($"Invalid format: '{input}'"); + + var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var unit = match.Groups[2].Value.ToUpperInvariant(); + + var multiplier = unit switch + { + "B" => 1L, + "KB" => 1L << 10, + "MB" => 1L << 20, + "GB" => 1L << 30, + "TB" => 1L << 40, + "PB" => 1L << 50, + "EB" => 1L << 60, + _ => throw new FormatException($"Unknown unit: '{unit}'") + }; + + return (long)(value * multiplier); + } + + [GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([KMGTPE]?B)\s*$", RegexOptions.IgnoreCase)] + private static partial Regex HumanReadableBytesRegex(); } diff --git a/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs b/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs new file mode 100644 index 000000000..d31e27389 --- /dev/null +++ b/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Extensions; + +namespace API.Helpers.Converters; + +public static class AnnotationFilterFieldValueConverter +{ + + public static object ConvertValue(AnnotationFilterField field, string value) + { + return field switch + { + AnnotationFilterField.Owner or + AnnotationFilterField.HighlightSlot or + AnnotationFilterField.Library => value.ParseIntArray(), + AnnotationFilterField.Spoiler => bool.Parse(value), + AnnotationFilterField.Selection => value, + AnnotationFilterField.Comment => value, + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + +} diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 631332f5f..80a625886 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -107,6 +107,7 @@ public static class FilterFieldValueConverter .ToList(), FilterField.ReadTime => string.IsNullOrEmpty(value) ? 0 : int.Parse(value), FilterField.AverageRating => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), + FilterField.FileSize => value.ParseHumanReadableBytes(), _ => throw new ArgumentException("Invalid field type") }; } diff --git a/API/I18N/en.json b/API/I18N/en.json index d0c86bcc6..35f070075 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -242,5 +242,7 @@ "generated-reading-profile-name": "Generated from {0}", "genre-doesnt-exist": "Genre doesn't exist", - "font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts" + "font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts", + "annotation-export-failed": "Unable to export Annotations, check logs", + "download-not-allowed": "User does not have download permissions" } diff --git a/API/Middleware/OpdsActionFilterAttribute.cs b/API/Middleware/OpdsActionFilterAttribute.cs new file mode 100644 index 000000000..f9cbb1191 --- /dev/null +++ b/API/Middleware/OpdsActionFilterAttribute.cs @@ -0,0 +1,64 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using API.Controllers; +using API.Data; +using API.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace API.Middleware; + +/// +/// 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 +{ + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + int userId; + try + { + if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey) + { + context.Result = new BadRequestResult(); + return; + } + + userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == null || userId == 0) + { + context.Result = new UnauthorizedResult(); + return; + } + + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.EnableOpds) + { + context.Result = new ContentResult + { + Content = await localizationService.Translate(userId, "opds-disabled"), + ContentType = "text/plain", + StatusCode = (int)HttpStatusCode.BadRequest, + }; + return; + } + } + catch (Exception ex) + { + logger.LogError(ex, "failed to handle OPDS request"); + context.Result = new BadRequestResult(); + return; + } + + // Add the UserId from ApiKey onto the OPDSController + context.HttpContext.Items.Add(OpdsController.UserId, userId); + + + await next(); + } + +} diff --git a/API/Middleware/OpdsActiveUserMiddlewareAttribute.cs b/API/Middleware/OpdsActiveUserMiddlewareAttribute.cs new file mode 100644 index 000000000..8e93db5df --- /dev/null +++ b/API/Middleware/OpdsActiveUserMiddlewareAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using API.Controllers; +using API.Data; +using API.SignalR.Presence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace API.Middleware; + +/// +/// Middleware that will track any API calls as updating the authenticated (ApiKey) user's LastActive and inform +/// +[AttributeUsage(AttributeTargets.Class)] +public class OpdsActiveUserMiddlewareAttribute(IUnitOfWork unitOfWork, IPresenceTracker presenceTracker, ILogger logger) : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + try + { + if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey) + { + await next(); + return; + } + + var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) + { + context.Result = new UnauthorizedResult(); + return; + } + + await unitOfWork.UserRepository.UpdateUserAsActive(userId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to count User as active during OPDS request"); + await next(); + return; + } + + await next(); + } +} diff --git a/API/Services/AnnotationService.cs b/API/Services/AnnotationService.cs new file mode 100644 index 000000000..75d2bc3e2 --- /dev/null +++ b/API/Services/AnnotationService.cs @@ -0,0 +1,278 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Annotations; +using API.DTOs.Reader; +using API.Entities; +using API.Helpers; +using API.SignalR; +using HtmlAgilityPack; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IAnnotationService +{ + Task CreateAnnotation(int userId, AnnotationDto dto); + Task UpdateAnnotation(int userId, AnnotationDto dto); + /// + /// Export all annotations for a user, or optionally specify which annotation exactly + /// + /// + /// + /// + Task ExportAnnotations(int userId, IList? annotationIds = null); +} + +public class AnnotationService : IAnnotationService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IBookService _bookService; + private readonly IDirectoryService _directoryService; + private readonly IEventHub _eventHub; + private readonly ILogger _logger; + + public AnnotationService(IUnitOfWork unitOfWork, IBookService bookService, + IDirectoryService directoryService, IEventHub eventHub, ILogger logger) + { + _unitOfWork = unitOfWork; + _bookService = bookService; + _directoryService = directoryService; + _eventHub = eventHub; + _logger = logger; + } + + /// + /// Create a new Annotation for the user against a Chapter + /// + /// + /// + /// + /// Message is not localized + public async Task CreateAnnotation(int userId, AnnotationDto dto) + { + try + { + if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText)) + { + throw new KavitaException("invalid-payload"); + } + + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId) ?? throw new KavitaException("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, + CommentHtml = dto.CommentHtml, + CommentPlainText = StripHtml(dto.CommentHtml), + ContainsSpoiler = dto.ContainsSpoiler, + PageNumber = dto.PageNumber, + SelectedSlotIndex = dto.SelectedSlotIndex, + AppUserId = userId, + Context = dto.Context, + ChapterTitle = chapterTitle + }; + + _unitOfWork.AnnotationRepository.Attach(annotation); + await _unitOfWork.CommitAsync(); + + return 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); + throw new KavitaException("annotation-failed-create"); + } + } + + /// + /// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation + /// + /// + /// + /// + /// Message is not localized + public async Task UpdateAnnotation(int userId, AnnotationDto dto) + { + try + { + var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id); + if (annotation == null || annotation.AppUserId != userId) throw new KavitaException("denied"); + + annotation.ContainsSpoiler = dto.ContainsSpoiler; + annotation.SelectedSlotIndex = dto.SelectedSlotIndex; + annotation.Comment = dto.Comment; + annotation.CommentHtml = dto.CommentHtml; + annotation.CommentPlainText = StripHtml(dto.CommentHtml); + + _unitOfWork.AnnotationRepository.Update(annotation); + + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + { + await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, + MessageFactory.AnnotationUpdateEvent(dto), userId); + return dto; + } + } catch (Exception ex) + { + _logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber); + } + + throw new KavitaException("generic-error"); + } + + public async Task ExportAnnotations(int userId, IList? annotationIds = null) + { + try + { + // Get users with preferences for highlight colors + var users = (await _unitOfWork.UserRepository + .GetAllUsersAsync(AppUserIncludes.UserPreferences)) + .ToDictionary(u => u.Id, u => u); + + // Get all annotations for the user with related data + IList annotations; + if (annotationIds == null) + { + annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotationsByUserIdAsync(userId); + } + else + { + annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotations(userId, annotationIds); + } + + // Get settings for hostname + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var hostname = !string.IsNullOrWhiteSpace(settings.HostName) ? settings.HostName : "http://localhost:5000"; + + // Group annotations by series, then by volume + var exportData = annotations + .GroupBy(a => new { a.SeriesId, a.SeriesName, a.LibraryId, a.LibraryName }) + .Select(seriesGroup => new + { + series = new + { + id = seriesGroup.Key.SeriesId, + title = seriesGroup.Key.SeriesName, + libraryName = seriesGroup.Key.LibraryName, + libraryId = seriesGroup.Key.LibraryId + }, + volumes = seriesGroup + .GroupBy(a => new { a.VolumeId, a.VolumeName }) + .Select(volumeGroup => new + { + id = volumeGroup.Key.VolumeId, + title = volumeGroup.Key.VolumeName, + annotations = volumeGroup.Select(annotation => + { + var user = users[annotation.UserId]; + var highlightSlot = user.UserPreferences.BookReaderHighlightSlots + .FirstOrDefault(slot => slot.SlotNumber == annotation.SelectedSlotIndex); + + var slotColor = highlightSlot != null + ? $"#{highlightSlot.Color.R:X2}{highlightSlot.Color.G:X2}{highlightSlot.Color.B:X2}" + : "#000000"; + + var deepLink = $"{hostname}/library/{annotation.LibraryId}/series/{annotation.SeriesId}/book/{annotation.ChapterId}?incognitoMode=true&annotationId={annotation.Id}"; + + var obsidianTitle = $"{seriesGroup.Key.SeriesName} - {volumeGroup.Key.VolumeName}"; + if (!string.IsNullOrWhiteSpace(annotation.ChapterTitle)) + { + obsidianTitle += $" - {annotation.ChapterTitle}"; + } + + return new + { + id = annotation.Id, + selectedText = annotation.SelectedText, + comment = annotation.CommentHtml, + context = annotation.Context, + chapterTitle = annotation.ChapterTitle, + pageNumber = annotation.PageNumber, + slotColor, + containsSpoiler = annotation.ContainsSpoiler, + deepLink, + createdUtc = annotation.CreatedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), + lastModifiedUtc = annotation.LastModifiedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), + obsidianTags = new[] { "#kavita", $"#{seriesGroup.Key.SeriesName.ToLowerInvariant().Replace(" ", "-")}", "#highlights" }, + obsidianTitle, + obsidianBacklinks = new[] { $"[[{seriesGroup.Key.SeriesName} Series]]", $"[[{volumeGroup.Key.VolumeName}]]" } + }; + }).ToArray(), + }).ToArray(), + }).ToArray(); + + // Serialize to JSON + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + var json = JsonSerializer.Serialize(exportData, options); + + _logger.LogInformation("Successfully exported {AnnotationCount} annotations for user {UserId}", annotations.Count, userId); + + return json; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export annotations for user {UserId}", userId); + throw new KavitaException("annotation-export-failed"); + } + } + + private string StripHtml(string? html) + { + if (string.IsNullOrEmpty(html)) + { + return string.Empty; + } + + try + { + var document = new HtmlDocument(); + document.LoadHtml(html); + + return document.DocumentNode.InnerText.Replace(" ", " "); + } + catch (Exception exception) + { + _logger.LogError(exception, "Invalid html, cannot parse plain text"); + return string.Empty; + } + } +} diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index ac7f6575c..8abde6647 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -233,7 +233,7 @@ public class LocalizationService : ILocalizationService RenderName = GetDisplayName(fileName), TranslationCompletion = 0, // Will be calculated later IsRtL = IsRightToLeft(fileName), - Hash = hash + Hash = hash, }; } else @@ -258,7 +258,15 @@ public class LocalizationService : ILocalizationService } } - var kavitaLocales = locales.Values.ToList(); + var validFileNames = uiLanguages + .Select(file => _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file)) + .Intersect(backendLanguages + .Select(file => _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file))) + .ToList(); + + var kavitaLocales = locales.Values + .Where(l => validFileNames.Contains(l.FileName)) + .ToList(); _cache.Set(LocaleCacheKey, kavitaLocales, _localsCacheOptions); return kavitaLocales; diff --git a/API/Services/OpdsService.cs b/API/Services/OpdsService.cs new file mode 100644 index 000000000..f65d9dc41 --- /dev/null +++ b/API/Services/OpdsService.cs @@ -0,0 +1,1289 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.DTOs.OPDS; +using API.DTOs.OPDS.Requests; +using API.DTOs.Person; +using API.DTOs.ReadingLists; +using API.DTOs.Search; +using API.Entities; +using API.Entities.Enums; +using API.Exceptions; +using API.Helpers; +using AutoMapper; + +namespace API.Services; +#nullable enable + +public interface IOpdsService +{ + Task GetCatalogue(OpdsCatalogueRequest request); + Task GetSmartFilters(OpdsPaginatedCatalogueRequest request); + Task GetLibraries(OpdsPaginatedCatalogueRequest request); + Task GetWantToRead(OpdsPaginatedCatalogueRequest request); + Task GetCollections(OpdsPaginatedCatalogueRequest request); + Task GetReadingLists(OpdsPaginatedCatalogueRequest request); + Task GetRecentlyAdded(OpdsPaginatedCatalogueRequest request); + Task GetRecentlyUpdated(OpdsPaginatedCatalogueRequest request); + Task GetOnDeck(OpdsPaginatedCatalogueRequest request); + + Task GetMoreInGenre(OpdsItemsFromEntityIdRequest request); + Task GetSeriesFromSmartFilter(OpdsItemsFromEntityIdRequest request); + Task GetSeriesFromCollection(OpdsItemsFromEntityIdRequest request); + Task GetSeriesFromLibrary(OpdsItemsFromEntityIdRequest request); + Task GetReadingListItems(OpdsItemsFromEntityIdRequest request); + Task GetSeriesDetail(OpdsItemsFromEntityIdRequest request); + Task GetItemsFromVolume(OpdsItemsFromCompoundEntityIdsRequest request); + Task GetItemsFromChapter(OpdsItemsFromCompoundEntityIdsRequest request); + + Task Search(OpdsSearchRequest request); + + string SerializeXml(Feed? feed); +} + +public class OpdsService : IOpdsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly ISeriesService _seriesService; + private readonly IDownloadService _downloadService; + private readonly IDirectoryService _directoryService; + private readonly IReaderService _readerService; + private readonly IMapper _mapper; + + private readonly XmlSerializer _xmlSerializer; + + private const int PageSize = 20; + private readonly FilterV2Dto _filterV2Dto = new(); + private readonly FilterDto _filterDto = new() + { + Formats = [], + Character = [], + Colorist = [], + Editor = [], + Genres = [], + Inker = [], + Languages = [], + Letterer = [], + Penciller = [], + Libraries = [], + Publisher = [], + Rating = 0, + Tags = [], + Translators = [], + Writers = [], + AgeRating = [], + CollectionTags = [], + CoverArtist = [], + ReadStatus = new ReadStatus(), + SortOptions = null, + PublicationStatus = [] + }; + + public OpdsService(IUnitOfWork unitOfWork, ILocalizationService localizationService, ISeriesService seriesService, + IDownloadService downloadService, IDirectoryService directoryService, IReaderService readerService, IMapper mapper) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _seriesService = seriesService; + _downloadService = downloadService; + _directoryService = directoryService; + _readerService = readerService; + _mapper = mapper; + + _xmlSerializer = new XmlSerializer(typeof(Feed)); + } + + public async Task GetCatalogue(OpdsCatalogueRequest request) + { + var feed = CreateFeed("Kavita", string.Empty, request.ApiKey, request.Prefix); + SetFeedId(feed, "root"); + + // Get the user's customized dashboard + var streams = await _unitOfWork.UserRepository.GetDashboardStreams(request.UserId, true); + foreach (var stream in streams) + { + switch (stream.StreamType) + { + case DashboardStreamType.OnDeck: + feed.Entries.Add(new FeedEntry() + { + Id = "onDeck", + Title = await _localizationService.Translate(request.UserId, "on-deck"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-on-deck") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/on-deck"), + ] + }); + break; + case DashboardStreamType.NewlyAdded: + feed.Entries.Add(new FeedEntry() + { + Id = "recentlyAdded", + Title = await _localizationService.Translate(request.UserId, "recently-added"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-recently-added") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/recently-added"), + ] + }); + break; + case DashboardStreamType.RecentlyUpdated: + feed.Entries.Add(new FeedEntry() + { + Id = "recentlyUpdated", + Title = await _localizationService.Translate(request.UserId, "recently-updated"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-recently-updated") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/recently-updated"), + ] + }); + break; + case DashboardStreamType.MoreInGenre: + var randomGenre = await _unitOfWork.GenreRepository.GetRandomGenre(); + if (randomGenre == null) break; + + feed.Entries.Add(new FeedEntry() + { + Id = "moreInGenre", + Title = await _localizationService.Translate(request.UserId, "more-in-genre", randomGenre.Title), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-more-in-genre", randomGenre.Title) + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/more-in-genre?genreId={randomGenre.Id}"), + ] + }); + break; + case DashboardStreamType.SmartFilter: + + feed.Entries.Add(new FeedEntry() + { + Id = "smartFilter-" + stream.Id, + Title = stream.Name, + Content = new FeedEntryContent() + { + Text = stream.Name + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{request.Prefix}{request.ApiKey}/smart-filters/{stream.SmartFilterId}/") + ] + }); + break; + } + } + + feed.Entries.Add(new FeedEntry() + { + Id = "readingList", + Title = await _localizationService.Translate(request.UserId, "reading-lists"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-reading-lists") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/reading-list"), + ] + }); + feed.Entries.Add(new FeedEntry() + { + Id = "wantToRead", + Title = await _localizationService.Translate(request.UserId, "want-to-read"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-want-to-read") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/want-to-read"), + ] + }); + feed.Entries.Add(new FeedEntry() + { + Id = "allLibraries", + Title = await _localizationService.Translate(request.UserId, "libraries"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-libraries") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/libraries"), + ] + }); + feed.Entries.Add(new FeedEntry() + { + Id = "allCollections", + Title = await _localizationService.Translate(request.UserId, "collections"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-collections") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/collections"), + ] + }); + + if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(request.UserId)).Any()) + { + feed.Entries.Add(new FeedEntry() + { + Id = "allSmartFilters", + Title = await _localizationService.Translate(request.UserId, "smart-filters"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(request.UserId, "browse-smart-filters") + }, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{request.Prefix}{request.ApiKey}/smart-filters"), + ] + }); + } + + return feed; + } + + public async Task GetSmartFilters(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var filters = await _unitOfWork.AppUserSmartFilterRepository.GetPagedDtosByUserIdAsync(userId, GetUserParams(request.PageNumber)); + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix); + SetFeedId(feed, "smartFilters"); + + foreach (var filter in filters) + { + feed.Entries.Add(new FeedEntry() + { + Id = filter.Id.ToString(), + Title = filter.Name, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/smart-filters/{filter.Id}") + ] + }); + } + + AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters"); + + return feed; + } + + public async Task GetLibraries(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix); + SetFeedId(feed, "libraries"); + + // TODO: This needs pagination and the query can be optimized + + // Ensure libraries follow SideNav order + var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId); + var libraries = userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library) + .Select(sideNavStream => sideNavStream.Library); + + foreach (var library in libraries) + { + feed.Entries.Add(new FeedEntry() + { + Id = library!.Id.ToString(), + Title = library.Name!, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/libraries/{library.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}") + ] + }); + } + + //AddPagination(feed, libraries, $"{prefix}{apiKey}/libraries"); + + return feed; + } + + public async Task GetWantToRead(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var wantToReadSeries = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(userId, GetUserParams(request.PageNumber), _filterV2Dto); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(wantToReadSeries.Select(s => s.Id)); + + var feed = CreateFeed(await _localizationService.Translate(userId, "want-to-read"), $"{apiKey}/want-to-read", apiKey, prefix); + SetFeedId(feed, $"want-to-read"); + AddPagination(feed, wantToReadSeries, $"{prefix}{apiKey}/want-to-read"); + + feed.Entries.AddRange(wantToReadSeries.Select(seriesDto => + CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl))); + + return feed; + } + + public async Task GetCollections(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosPagedAsync(userId, GetUserParams(request.PageNumber), true); + + var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix); + SetFeedId(feed, "collections"); + + + feed.Entries.AddRange(tags.Select(tag => new FeedEntry() + { + Id = tag.Id.ToString(), + Title = tag.Title, + Summary = tag.Summary, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/collections/{tag.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") + ] + })); + + AddPagination(feed, tags, $"{prefix}{apiKey}/collections"); + + return feed; + } + + public async Task GetRecentlyAdded(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(request.PageNumber), _filterV2Dto); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); + + var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{apiKey}/recently-added", apiKey, prefix); + SetFeedId(feed, "recently-added"); + AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added"); + + foreach (var seriesDto in recentlyAdded) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + return feed; + } + + public async Task GetRecentlyUpdated(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, GetUserParams(request.PageNumber))).ToList(); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId)); + + var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix); + SetFeedId(feed, "recently-updated"); + + foreach (var groupedSeries in seriesDtos) + { + var seriesDto = new SeriesDto() + { + Name = $"{groupedSeries.SeriesName} ({groupedSeries.Count})", + Id = groupedSeries.SeriesId, + Format = groupedSeries.Format, + LibraryId = groupedSeries.LibraryId, + }; + var metadata = seriesMetadatas.First(s => s.SeriesId == seriesDto.Id); + feed.Entries.Add(CreateSeries(seriesDto, metadata, apiKey, prefix, baseUrl)); + } + AddPagination(feed, request.PageNumber, 0, PageSize, $"{apiKey}/recently-updated"); + + return feed; + } + + public async Task GetOnDeck(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, GetUserParams(request.PageNumber), _filterDto); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id)); + + var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{apiKey}/on-deck", apiKey, prefix); + SetFeedId(feed, "on-deck"); + AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck"); + + foreach (var seriesDto in pagedList) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + return feed; + } + + public async Task GetMoreInGenre(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var genreId = request.EntityId; + + var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId); + if (genre == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "genre-doesnt-exist")); + } + var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(request.PageNumber)); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id)); + + var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{apiKey}/more-in-genre", apiKey, prefix); + SetFeedId(feed, "more-in-genre"); + AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/more-in-genre"); + + foreach (var seriesDto in seriesDtos) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + return feed; + } + + /// + /// Returns the Series matching this smart filter. + /// + /// + /// + public async Task GetSeriesFromSmartFilter(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(request.EntityId); + if (filter == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "smart-filter-doesnt-exist")); + } + + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix); + SetFeedId(feed, "smartFilters-" + filter.Id); + + var decodedFilter = SmartFilterHelper.Decode(filter.Filter); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(request.PageNumber), + decodedFilter); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); + + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + AddPagination(feed, series, $"{prefix}{apiKey}/smart-filters/{request.EntityId}/"); + + return feed; + } + + public async Task GetSeriesFromCollection(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var collectionId = request.EntityId; + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId); + if (tag == null || (tag.AppUserId != userId && !tag.Promoted)) + { + throw new OpdsException(await _localizationService.Translate(userId, "collection-doesnt-exist")); + } + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, GetUserParams(request.PageNumber)); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); + + var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey, prefix); + SetFeedId(feed, $"collections-{collectionId}"); + AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}"); + + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + return feed; + } + + public async Task GetSeriesFromLibrary(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var libraryId = request.EntityId; + + var library = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)) + .SingleOrDefault(l => l.Id == libraryId); + + if (library == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "no-library-access")); + } + + var filter = new FilterV2Dto + { + Statements = [ + new FilterStatementDto + { + Comparison = FilterComparison.Equal, + Field = FilterField.Libraries, + Value = libraryId + string.Empty + } + ] + }; + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(request.PageNumber), filter); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); + + var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix); + SetFeedId(feed, $"library-{library.Name}"); + AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}"); + + feed.Entries.AddRange(series.Select(seriesDto => + CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl))); + + return feed; + } + + + public async Task GetReadingListItems(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var readingListId = request.EntityId; + + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId); + if (readingList == null) + { + throw new OpdsException(await _localizationService.Translate(request.UserId, "reading-list-restricted")); + } + + var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); + SetFeedId(feed, $"reading-list-{readingListId}"); + + + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(request.PageNumber))).ToList(); + var totalItems = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).Count(); + + + // Check if there is reading progress or not, if so, inject a "continue-reading" item + var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0); + if (firstReadReadingListItem != null && request.PageNumber == 0) + { + await AddContinueReadingPoint(firstReadReadingListItem, feed, request); + } + + foreach (var item in items) + { + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId); + + // If there is only one file underneath, add a direct acquisition link, otherwise add a subsection + if (chapterDto is {Files.Count: 1}) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId); + feed.Entries.Add(await CreateChapterWithFile(item.SeriesId, item.VolumeId, item.ChapterId, + chapterDto.Files.First(), series!, chapterDto, request)); + } + else + { + feed.Entries.Add( + CreateChapter($"{item.Order} - {item.SeriesName}: {item.Title}", + item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, request)); + } + } + + AddPagination(feed, request.PageNumber, totalItems, UserParams.Default.PageSize, $"{prefix}{apiKey}/reading-list/{readingListId}/"); + + return feed; + } + + public async Task GetSeriesDetail(OpdsItemsFromEntityIdRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var seriesId = request.EntityId; + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + + var feed = CreateFeed(series!.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey, prefix); + SetFeedId(feed, $"series-{series.Id}"); + feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); + + + // Check if there is reading progress or not, if so, inject a "continue-reading" item + var anyUserProgress = await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); + if (anyUserProgress) + { + var chapterDto = await _readerService.GetContinuePoint(seriesId, userId); + await AddContinueReadingPoint(seriesId, chapterDto, feed, request); + } + + + var chapterDict = new Dictionary(); + var fileDict = new Dictionary(); + + var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); + foreach (var volume in seriesDetail.Volumes) + { + var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChapterDtosAsync(volume.Id, userId); + + foreach (var chapterDto in chaptersForVolume) + { + var chapterId = chapterDto.Id; + if (!chapterDict.TryAdd(chapterId, 0)) continue; + + foreach (var mangaFile in chapterDto.Files) + { + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + + feed.Entries.Add(await CreateChapterWithFile(seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, + chapterDto, request)); + } + } + } + + var chapters = seriesDetail.StorylineChapters; + if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any()) + { + chapters = seriesDetail.Chapters; + } + + foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id))) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); + var chapterDto = _mapper.Map(chapter); + foreach (var mangaFile in files) + { + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + feed.Entries.Add(await CreateChapterWithFile(seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series, + chapterDto, request)); + } + } + + foreach (var special in seriesDetail.Specials) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id); + var chapterDto = _mapper.Map(special); + foreach (var mangaFile in files) + { + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + + feed.Entries.Add(await CreateChapterWithFile(seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series, + chapterDto, request)); + } + } + + return feed; + } + + public async Task GetItemsFromVolume(OpdsItemsFromCompoundEntityIdsRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var seriesId = request.SeriesId; + var volumeId = request.VolumeId; + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + if (series == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "series-doesnt-exist")); + } + + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + if (volume == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "volume-doesnt-exist")); + } + + var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}", + $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}"); + + var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId); + + // Check if there is reading progress or not, if so, inject a "continue-reading" item + var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0); + if (firstChapterWithProgress != null) + { + var chapterDto = await _readerService.GetContinuePoint(seriesId, userId); + await AddContinueReadingPoint(seriesId, chapterDto, feed, request); + } + + foreach (var chapterDto in chapterDtos) + { + foreach (var mangaFile in chapterDto.Files) + { + feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, request)); + } + } + + return feed; + } + + public async Task GetItemsFromChapter(OpdsItemsFromCompoundEntityIdsRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var seriesId = request.SeriesId; + var volumeId = request.VolumeId; + var chapterId = request.ChapterId; + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + if (series == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "series-doesnt-exist")); + } + + var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId); + + if (chapter == null) + { + throw new OpdsException(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + } + + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); + + var chapterName = await _seriesService.FormatChapterName(userId, libraryType); + var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}", + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); + SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files"); + + foreach (var mangaFile in chapter.Files) + { + feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request)); + } + + return feed; + } + + public async Task Search(OpdsSearchRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + var query = request.Query; + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + + if (string.IsNullOrEmpty(query)) + { + throw new OpdsException(await _localizationService.Translate(userId, "query-required")); + } + query = query.Replace("%", string.Empty); + + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); + if (libraries.Count == 0) + { + throw new OpdsException(await _localizationService.Translate(userId, "libraries-restricted")); + } + + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, + libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false); + + var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix); + SetFeedId(feed, "search-series"); + foreach (var seriesDto in searchResults.Series) + { + feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl)); + } + + foreach (var collection in searchResults.Collections) + { + feed.Entries.Add(new FeedEntry() + { + Id = collection.Id.ToString(), + Title = collection.Title, + Summary = collection.Summary, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/collections/{collection.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}") + ] + }); + } + + foreach (var readingListDto in searchResults.ReadingLists) + { + feed.Entries.Add(new FeedEntry() + { + Id = readingListDto.Id.ToString(), + Title = readingListDto.Title, + Summary = readingListDto.Summary, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), + ] + }); + } + + feed.Total = feed.Entries.Count; + + return feed; + } + + public async Task GetReadingLists(OpdsPaginatedCatalogueRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, + true, GetUserParams(request.PageNumber), false); + + + var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix); + SetFeedId(feed, "reading-list"); + AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/"); + + foreach (var readingListDto in readingLists) + { + feed.Entries.Add(new FeedEntry() + { + Id = readingListDto.Id.ToString(), + Title = readingListDto.Title, + Summary = readingListDto.Summary, + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}") + ] + }); + } + + return feed; + } + + private static int UnpackRequest(IOpdsRequest request, out string apiKey, out string prefix, + out string baseUrl) + { + var userId = request.UserId; + apiKey = request.ApiKey; + prefix = request.Prefix; + baseUrl = request.BaseUrl; + + return userId; + } + + + public string SerializeXml(Feed? feed) + { + if (feed == null) return string.Empty; + + // Remove invalid XML characters from the feed object + SanitizeFeed(feed); + + using var sm = new StringWriter(); + _xmlSerializer.Serialize(sm, feed); + + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; + } + + // Recursively sanitize all string properties in the object + private static void SanitizeFeed(object? obj) + { + if (obj == null) return; + + var properties = obj.GetType().GetProperties(); + foreach (var property in properties) + { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + + if (property.PropertyType == typeof(string) && property.CanWrite) + { + var value = (string?)property.GetValue(obj); + if (!string.IsNullOrEmpty(value)) + { + property.SetValue(obj, RemoveInvalidXmlChars(value)); + } + } + else if (property.PropertyType.IsClass) // Handle nested objects + { + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); + } + } + } + + private static string RemoveInvalidXmlChars(string input) + { + return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); + } + + + private static void SetFeedId(Feed feed, string id) + { + feed.Id = id; + } + + + private static FeedLink CreateLink(string rel, string type, string href, string? title = null) + { + return new FeedLink() + { + Rel = rel, + Href = href, + Type = type, + Title = string.IsNullOrEmpty(title) ? string.Empty : title + }; + } + + private static Feed CreateFeed(string title, string href, string apiKey, string prefix) + { + var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ? + FeedLinkType.AtomNavigation : + FeedLinkType.AtomAcquisition, prefix + href); + + return new Feed() + { + Title = title, + Icon = $"{prefix}{apiKey}/favicon", + Links = + [ + link, + CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"), + CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search") + ], + }; + } + + private static void AddPagination(Feed feed, PagedList list, string href) + { + var url = href; + if (href.Contains('?')) + { + url += "&"; + } + else + { + url += "?"; + } + + var pageNumber = Math.Max(list.CurrentPage, 1); + + if (pageNumber > 1) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); + } + + if (pageNumber + 1 <= list.TotalPages) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); + } + + // Update self to point to current page + var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); + if (selfLink != null) + { + selfLink.Href = url + "pageNumber=" + pageNumber; + } + + + feed.Total = list.TotalCount; + feed.ItemsPerPage = list.PageSize; + feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1; + } + + private static void AddPagination(Feed feed, int currentPage, int totalItems, int pageSize, string href) + { + var url = href; + if (href.Contains('?')) + { + url += "&"; + } + else + { + url += "?"; + } + + var pageNumber = Math.Max(currentPage, 1); + var totalPages = totalItems / pageSize; + + if (pageNumber > 1) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); + } + + if (pageNumber + 1 <= totalPages) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); + } + + // Update self to point to current page + var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); + if (selfLink != null) + { + selfLink.Href = url + "pageNumber=" + pageNumber; + } + + + feed.Total = totalItems; + feed.ItemsPerPage = pageSize; + feed.StartIndex = (Math.Max(currentPage - 1, 0) * pageSize) + 1; + } + + + private static FeedEntry CreateSeries(SeriesDto seriesDto, SeriesMetadataDto metadata, string apiKey, string prefix, string baseUrl) + { + return new FeedEntry() + { + Id = seriesDto.Id.ToString(), + Title = $"{seriesDto.Name}", + Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary) + ? string.Empty + : $" Summary: {metadata.Summary}"), + Authors = metadata.Writers.Select(CreateAuthor).ToList(), + Categories = metadata.Genres.Select(g => new FeedCategory() + { + Label = g.Title, + Term = string.Empty + }).ToList(), + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{seriesDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") + ] + }; + } + + private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey, string prefix, string baseUrl) + { + return new FeedEntry() + { + Id = searchResultDto.SeriesId.ToString(), + Title = $"{searchResultDto.Name}", + Summary = $"Format: {searchResultDto.Format}", + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}") + ] + }; + } + + private static FeedAuthor CreateAuthor(PersonDto person) + { + return new FeedAuthor() + { + Name = person.Name, + Uri = "http://opds-spec.org/author/" + person.Id + }; + } + + private static FeedEntry CreateChapter(string title, string? summary, int chapterId, int volumeId, int seriesId, IOpdsRequest request) + { + var userId = UnpackRequest(request, out var apiKey, out var prefix, out var baseUrl); + return new FeedEntry() + { + Id = chapterId.ToString(), + Title = title, + Summary = summary ?? string.Empty, + + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}") + ] + }; + } + + private async Task CreateContinueReadingFromFile(int seriesId, int volumeId, int chapterId, + MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request) + { + var entry = await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request); + + entry.Title = await _localizationService.Translate(request.UserId, "opds-continue-reading-title", entry.Title); + + return entry; + } + + private async Task CreateChapterWithFile(int seriesId, int volumeId, int chapterId, + MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request) + { + var fileSize = + mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : + DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize((List) [mangaFile.FilePath])); + var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); + var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath)); + var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, request.UserId); + + + var title = $"{series.Name}"; + + if (volume!.Chapters.Count == 1 && !volume.IsSpecial()) + { + var volumeLabel = await _localizationService.Translate(request.UserId, "volume-num", string.Empty); + SeriesService.RenameVolumeName(volume, libraryType, volumeLabel); + + if (!volume.IsLooseLeaf()) + { + title += $" - {volume.Name}"; + } + } + else if (!volume.IsLooseLeaf() && !volume.IsSpecial()) + { + title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(request.UserId, chapter, libraryType)}"; + } + else + { + title = $"{series.Name} - {await _seriesService.FormatChapterTitle(request.UserId, chapter, libraryType)}"; + } + + // Chunky requires a file at the end. Our API ignores this + var accLink = CreateLink(FeedLinkRelation.Acquisition, fileType, + $"{request.Prefix}{request.ApiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}", + filename); + + accLink.TotalPages = chapter.Pages; + + var entry = new FeedEntry() + { + Id = mangaFile.Id.ToString(), + Title = title, + Extent = fileSize, + Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary) + ? string.Empty + : $" Summary: {chapter.Summary}"), + Format = mangaFile.Format.ToString(), + Links = + [ + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{request.BaseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={request.ApiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{request.BaseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={request.ApiKey}"), + // We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly + accLink + ], + Content = new FeedEntryContent() + { + Text = fileType, + Type = "text" + }, + Authors = chapter.Writers.Select(CreateAuthor).ToList() + }; + + var canPageStream = mangaFile.Extension != ".epub"; + if (canPageStream) + { + entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, request)); + } + + // Patch in reading status on the item (as OPDS is seriously lacking) + entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}"; + + return entry; + } + + private static string GetReadingProgressIcon(int pagesRead, int totalPages) + { + if (pagesRead == 0) return "⭘"; + + var percentageRead = (double)pagesRead / totalPages; + + return percentageRead switch + { + // 100% + >= 1.0 => "⬤", + // > 50% and < 100% + > 0.5 => "◕", + // > 25% and <= 50% + > 0.25 => "◑", + _ => "◔" + }; + } + + private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, IOpdsRequest request) + { + var userId = request.UserId; + var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); + + // NOTE: Type could be wrong, there is nothing I can do in the spec + var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", + $"{request.Prefix}{request.ApiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); + link.TotalPages = mangaFile.Pages; + link.IsPageStream = true; + + if (progress != null) + { + link.LastRead = progress.PageNum; + link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601 + } + + return link; + } + + private static UserParams GetUserParams(int pageNumber) + { + return new UserParams() + { + PageNumber = pageNumber, + PageSize = PageSize + }; + } + + private async Task AddContinueReadingPoint(int seriesId, ChapterDto chapterDto, Feed feed, IOpdsRequest request) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, request.UserId); + if (chapterDto is {Files.Count: 1}) + { + feed.Entries.Add(await CreateContinueReadingFromFile(seriesId, chapterDto.VolumeId, chapterDto.Id, + chapterDto.Files.First(), series!, chapterDto, request)); + } + } + + private async Task AddContinueReadingPoint(ReadingListItemDto firstReadReadingListItem, Feed feed, IOpdsRequest request) + { + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, request.UserId); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, request.UserId); + if (chapterDto is {Files.Count: 1}) + { + feed.Entries.Add(await CreateContinueReadingFromFile(firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId, + firstReadReadingListItem.ChapterId, chapterDto.Files.First(), series!, chapterDto, request)); + } + } +} diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 91f5a8fdd..a58a9cb6c 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -33,6 +33,7 @@ public interface ILicenseService Task HasActiveSubscription(string? license); Task ResetLicense(string license, string email); Task GetLicenseInfo(bool forceCheck = false); + Task ResendWelcomeEmail(); } public class LicenseService( @@ -305,4 +306,34 @@ public class LicenseService( return null; } + + /// + /// Attempts to resend a welcome email to the registered user. The sub does not need to be active. + /// + /// + public async Task ResendWelcomeEmail() + { + try + { + var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + if (string.IsNullOrEmpty(encryptedLicense.Value)) return false; + + var httpResponse = await (Configuration.KavitaPlusApiUrl + "/api/license/resend-welcome-email") + .WithKavitaPlusHeaders(encryptedLicense.Value) + .PostAsync(); + + var response = await httpResponse.GetStringAsync(); + + if (response == null) return false; + + + return response == "true"; + } + catch (FlurlHttpException e) + { + logger.LogError(e, "An error happened during the request to Kavita+ API"); + } + + return false; + } } diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs index e76f1a837..4c3dab006 100644 --- a/API/Services/ReadingProfileService.cs +++ b/API/Services/ReadingProfileService.cs @@ -445,7 +445,6 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService existingProfile.BookThemeName = dto.BookReaderThemeName; existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode; existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode; - existingProfile.BookReaderEpubPageCalculationMethod = dto.BookReaderEpubPageCalculationMethod; // PDF Reading existingProfile.PdfTheme = dto.PdfTheme; diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 720d97663..d77845b1f 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -118,13 +118,11 @@ public class TokenService : ITokenService try { - user.UpdateLastActive(); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id); } catch (Exception ex) { - _logger.LogError(ex, "There was an error updating last active for the user"); + _logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName); } return new TokenRequestDto() diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index 600a4197a..c9ca0b775 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -19,7 +19,7 @@ public interface IPresenceTracker internal class ConnectionDetail { public string UserName { get; set; } - public List ConnectionIds { get; set; } = new List(); + public List ConnectionIds { get; set; } = []; public bool IsAdmin { get; set; } } @@ -29,7 +29,7 @@ internal class ConnectionDetail public class PresenceTracker : IPresenceTracker { private readonly IUnitOfWork _unitOfWork; - private static readonly Dictionary OnlineUsers = new Dictionary(); + private static readonly Dictionary OnlineUsers = new(); public PresenceTracker(IUnitOfWork unitOfWork) { @@ -63,9 +63,9 @@ public class PresenceTracker : IPresenceTracker { lock (OnlineUsers) { - if (!OnlineUsers.ContainsKey(userId)) return Task.CompletedTask; + if (!OnlineUsers.TryGetValue(userId, out var user)) return Task.CompletedTask; - OnlineUsers[userId].ConnectionIds.Remove(connectionId); + user.ConnectionIds.Remove(connectionId); if (OnlineUsers[userId].ConnectionIds.Count == 0) { diff --git a/UI/Web/README.md b/UI/Web/README.md index e3a3d3592..65b9fb8bd 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -37,3 +37,8 @@ Run `npm run start` # Update latest angular `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` + +`npm install @angular-eslint/builder@latest @angular-eslint/eslint-plugin@latest @angular-eslint/eslint-plugin-template@latest @angular-eslint/schematics@latest @angular-eslint/template-parser@latest` + +# Update Localization library +`npm install @jsverse/transloco@latest @jsverse/transloco-locale@latest @jsverse/transloco-persist-lang@latest @jsverse/transloco-persist-translations@latest @jsverse/transloco-preload-langs@latest` diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index bbe42d392..a86ee6586 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -9,25 +9,25 @@ "version": "0.7.12.1", "dependencies": { "@angular-slider/ngx-slider": "^20.0.0", - "@angular/animations": "^20.2.3", - "@angular/cdk": "^20.2.1", - "@angular/common": "^20.2.3", - "@angular/compiler": "^20.2.3", - "@angular/core": "^20.2.3", - "@angular/forms": "^20.2.3", - "@angular/localize": "^20.2.3", - "@angular/platform-browser": "^20.2.3", - "@angular/platform-browser-dynamic": "^20.2.3", - "@angular/router": "^20.2.3", + "@angular/animations": "^20.3.2", + "@angular/cdk": "^20.2.5", + "@angular/common": "^20.3.2", + "@angular/compiler": "^20.3.2", + "@angular/core": "^20.3.2", + "@angular/forms": "^20.3.2", + "@angular/localize": "^20.3.2", + "@angular/platform-browser": "^20.3.2", + "@angular/platform-browser-dynamic": "^20.3.2", + "@angular/router": "^20.3.2", "@fortawesome/fontawesome-free": "^7.0.1", "@iharbeck/ngx-virtual-scroller": "^19.0.1", "@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", + "@jsverse/transloco": "^8.0.2", + "@jsverse/transloco-locale": "^8.0.2", + "@jsverse/transloco-persist-lang": "^8.0.2", + "@jsverse/transloco-persist-translations": "^8.0.2", + "@jsverse/transloco-preload-langs": "^8.0.2", "@microsoft/signalr": "^9.0.6", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.7", @@ -46,7 +46,7 @@ "ngx-file-drop": "^16.0.0", "ngx-quill": "^28.0.1", "ngx-stars": "^1.6.5", - "ngx-toastr": "^19.0.0", + "ngx-toastr": "^19.1.0", "nosleep.js": "^0.12.0", "rxjs": "^7.8.2", "screenfull": "^6.0.2", @@ -55,21 +55,21 @@ "zone.js": "^0.15.1" }, "devDependencies": { - "@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.2.1", - "@angular/cli": "^20.2.1", - "@angular/compiler-cli": "^20.2.3", + "@angular-eslint/builder": "^20.3.0", + "@angular-eslint/eslint-plugin": "^20.3.0", + "@angular-eslint/eslint-plugin-template": "^20.3.0", + "@angular-eslint/schematics": "^20.3.0", + "@angular-eslint/template-parser": "^20.3.0", + "@angular/build": "^20.3.3", + "@angular/cli": "^20.3.3", + "@angular/compiler-cli": "^20.3.2", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.6.2", "@types/marked": "^5.0.2", "@types/node": "^24.0.14", "@typescript-eslint/eslint-plugin": "^8.38.0", - "@typescript-eslint/parser": "^8.42.0", + "@typescript-eslint/parser": "^8.44.1", "eslint": "^9.31.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", @@ -309,13 +309,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2002.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2002.1.tgz", - "integrity": "sha512-8jotVFz+83avTdeRoLe7wn/F+nnbjywuVHqZ/shDGRHssOtR8fkSCjSsKwPZejU6wsgTxAKFylWRIxydZE8Hzw==", + "version": "0.2003.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.3.tgz", + "integrity": "sha512-DOnGyv9g24vaDzf5koLOcVri1kYJIBD9UKiJWOWk4H5cFlcpTXQ+PilPmDq6A+X94Tt4MZHImmKsk6LLRPIwFg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.2.1", + "@angular-devkit/core": "20.3.3", "rxjs": "7.8.2" }, "engines": { @@ -325,9 +325,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.2.1.tgz", - "integrity": "sha512-07xiRltPA1X+C0AQo/glI0in+bpwGW1cgOen2pp0MhXVlawW1M9cKZFb/35uvYUEWJUxLwBB3ZKJXBmpWWw0Rg==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.3.tgz", + "integrity": "sha512-2T5mX2duLapZYPYmXUSUe9VW8Dhu10nVBVvEp31jSE6xvjbPM5mlsv6+fks1E4RjhzvaamY9bm3WgwYwNiEV5g==", "dev": true, "license": "MIT", "dependencies": { @@ -353,13 +353,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.2.1.tgz", - "integrity": "sha512-hxQQhlOKLjj4+fJrvMFWnVA6vwewwtkEGneolY+aMb8dUAEE7sw1FLo02pPdIBIXLWIYIcGVRI0E5iCTcLq9zw==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.3.tgz", + "integrity": "sha512-LDn39BjyQLAK/DaVamLElMtI0UoCZIs4jKcMEv8PJ/nnBmrYFHVavWPggeFWMycjeXsdX34Msiml88HZWlXypw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.2.1", + "@angular-devkit/core": "20.3.3", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "8.2.0", @@ -568,9 +568,9 @@ } }, "node_modules/@angular-eslint/builder": { - "version": "20.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-20.1.1.tgz", - "integrity": "sha512-pfCYfocX79CZ5nokZF4gVScUGyLWRKQHZsUkQ5V/1hsaGsahvzDRjxsYz0J9rO0ligSa2pwgUCXEwSY8hhHQBw==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-20.3.0.tgz", + "integrity": "sha512-3XpWLdh+/K4+r0ChkKW00SXWyBA7ShMpE+Pt1XUmIu4srJgGRnt8e+kC4Syi+s2t5QS7PjlwRaelB1KfSMXZ5A==", "dev": true, "license": "MIT", "dependencies": { @@ -582,15 +582,22 @@ "typescript": "*" } }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.3.0.tgz", + "integrity": "sha512-QwuNnmRNr/uNj89TxknPbGcs5snX1w7RoJJPNAsfb2QGcHzUTQovS8hqm9kaDZdpUJDPP7jt7B6F0+EjrPAXRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-eslint/eslint-plugin": { - "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==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-20.3.0.tgz", + "integrity": "sha512-7ghzGTiExrgTetDQ6IPP5uXSa94Xhtzp2VHCIa58EcUb7oMv06HWZ1Uss3xgFmACsLpN+vayKJIdFiboqaGVRA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "20.1.1", - "@angular-eslint/utils": "20.1.1", + "@angular-eslint/bundled-angular-compiler": "20.3.0", + "@angular-eslint/utils": "20.3.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { @@ -600,80 +607,36 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "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==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-20.3.0.tgz", + "integrity": "sha512-WMJDJfybOLCiN4QrOyrLl+Zt5F+A/xoDYMWTdn+LgACheLs2tguVQiwf+oCgHnHGcsTsulPYlRHldKBGZMgs4w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "20.1.1", - "@angular-eslint/utils": "20.1.1", + "@angular-eslint/bundled-angular-compiler": "20.3.0", + "@angular-eslint/utils": "20.3.0", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { - "@angular-eslint/template-parser": "20.1.1", + "@angular-eslint/template-parser": "20.3.0", "@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/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-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==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-20.3.0.tgz", + "integrity": "sha512-4n92tHKIJm1PP+FjhnmO7AMpvKdRIoF+YgF38oUU7aMJqfZ3RXIhazMMxw2u3VU1MisKH766KSll++c4LgarVA==", "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", + "@angular-eslint/eslint-plugin": "20.3.0", + "@angular-eslint/eslint-plugin-template": "20.3.0", "ignore": "7.0.5", "semver": "7.7.2", "strip-json-comments": "3.1.1" @@ -690,13 +653,13 @@ } }, "node_modules/@angular-eslint/template-parser": { - "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==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-20.3.0.tgz", + "integrity": "sha512-gB564h/kZ7siWvgHDETU++sk5e25qFfVaizLaa6KoBEYFP6dOCiedz15LTcA0TsXp0rGu6Z6zkl291iSM1qzDA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "20.1.1", + "@angular-eslint/bundled-angular-compiler": "20.3.0", "eslint-scope": "^8.0.2" }, "peerDependencies": { @@ -704,12 +667,20 @@ "typescript": "*" } }, - "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==", + "node_modules/@angular-eslint/utils": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-20.3.0.tgz", + "integrity": "sha512-7XOQeNXgyhznDwoP1TwPrCMq/uXKJHQgCVPFREkJGKbNf/jzNldB7iV1eqpBzUQIPEQFgfcDG67dexpMAq3N4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "20.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } }, "node_modules/@angular-slider/ngx-slider": { "version": "20.0.0", @@ -728,9 +699,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.2.3.tgz", - "integrity": "sha512-cyON3oVfaotz8d8DHP3uheC/XDG2gJD8aiyuG/SEAZ2X1S/tAHdVetESbDZM830lLdi+kB/3GBrMbWCCpMWD7Q==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.2.tgz", + "integrity": "sha512-za7onSElEUbaI9iS8j7nKf8FjyvVng6wFsb2ZuHxr71dMgnYkqPfMu0KMP+mkZ3yUVc//7SllXcSkGBHShyCcw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -739,19 +710,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.2.3", - "@angular/core": "20.2.3" + "@angular/core": "20.3.2" } }, "node_modules/@angular/build": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.2.1.tgz", - "integrity": "sha512-FLiNDUhqCkU7EyODwPl8EZMubWdQG62ynczeLcHGtHOA2/Wiv+CvCP58GbuznZSslEcyyyE7MsEy3ZvsjxZuIA==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.3.tgz", + "integrity": "sha512-WhwAbovHAxDbNeR5jB2IS/SVs+yQg9NETFeJ5f7T3n/414ULkGOhXn+29i1rzwJhf1uqM9lsedcv2tKn1N24/A==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2002.1", + "@angular-devkit/architect": "0.2003.3", "@babel/core": "7.28.3", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -769,12 +739,12 @@ "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.3", "piscina": "5.1.3", - "rolldown": "1.0.0-beta.32", + "rolldown": "1.0.0-beta.38", "sass": "1.90.0", "semver": "7.7.2", "source-map-support": "0.5.21", "tinyglobby": "0.2.14", - "vite": "7.1.2", + "vite": "7.1.5", "watchpack": "2.4.4" }, "engines": { @@ -793,7 +763,7 @@ "@angular/platform-browser": "^20.0.0", "@angular/platform-server": "^20.0.0", "@angular/service-worker": "^20.0.0", - "@angular/ssr": "^20.2.1", + "@angular/ssr": "^20.3.3", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^20.0.0", @@ -859,9 +829,9 @@ } }, "node_modules/@angular/cdk": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.1.tgz", - "integrity": "sha512-yEPh5hr9LZW4ey/HxtaGdSBDIkNzziLo0Dr1RP8JcxhOQ2Bzv2PZ+g8jC6aPGD7NPV8FtDf0FhTEzQr+m+gBXQ==", + "version": "20.2.5", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.5.tgz", + "integrity": "sha512-1cpR/5jeKXLR1D+PsEvRn0QhSWD3/AjtbugJF5nlx/7L90YXhNFCmNAxAkdFKSn4YIDoPwMHgvOpS7yb51wohQ==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -874,19 +844,19 @@ } }, "node_modules/@angular/cli": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.2.1.tgz", - "integrity": "sha512-uKuq4+7EcEer7ixe6cYAAe8/WOvDIbLd/F7ZCMCb5dCGkGRoQKgodo6sorwZUpGvyuXO+mCYarTXzrBrY2b/Cg==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.3.tgz", + "integrity": "sha512-3c8xCklJ0C0T6ETSncAoXlOYNi3x7vLT3PS56rIaQ0jtlvD4Y+RQakd3+iffVAapvh/JB27WNor8pJRThLZ/jg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2002.1", - "@angular-devkit/core": "20.2.1", - "@angular-devkit/schematics": "20.2.1", + "@angular-devkit/architect": "0.2003.3", + "@angular-devkit/core": "20.3.3", + "@angular-devkit/schematics": "20.3.3", "@inquirer/prompts": "7.8.2", "@listr2/prompt-adapter-inquirer": "3.0.1", "@modelcontextprotocol/sdk": "1.17.3", - "@schematics/angular": "20.2.1", + "@schematics/angular": "20.3.3", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.35.0", "ini": "5.0.0", @@ -1076,9 +1046,9 @@ } }, "node_modules/@angular/common": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.2.3.tgz", - "integrity": "sha512-QLffWL8asy2oG7p3jvoNmx9s1V1WuJAm6JmQ1S8J3AN/BxumCJan49Nj8rctP8J4uwJDPQV48hqbXUdl1v7CDg==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.2.tgz", + "integrity": "sha512-5V9AzLhCA1dNhF+mvihmdHoZHbEhIb1jNYRA1/JMheR+G7NR8Mznu6RmWaKSWZ4AJeSJN8rizWN2wpVPWTKjSQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1087,14 +1057,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.2.3", + "@angular/core": "20.3.2", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.2.3.tgz", - "integrity": "sha512-vYGDluko8zAIWhQmKijhcGO0tzanwGONDRgbJ01mCqUsQV+XwmDgUUDZKrUY9uve0wxxM3Xvo4/BjEpGpeG75w==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.2.tgz", + "integrity": "sha512-5fSzkPmRomZ9H43c82FJWLwdOi7MICMimP1y1oYJZcUh3jYRhXUrQvD0jifdRVkkgKNjaZYlMr0NkrYQFgFong==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1104,9 +1074,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.2.3.tgz", - "integrity": "sha512-adLyPXmKbH8VZJCyOraaha+RPTdAjEBRTqwZ5YkjkViTMMANFkuj1w3pDwQsG3LFknRJ99aym+9neGINeAaI7A==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.2.tgz", + "integrity": "sha512-rLox2THiALVQqYGUaxZ6YD8qUoXIOGTw3s0tim9/U65GuXGRtYgG0ZQWYp3yjEBes0Ksx2/15eFPp1Ol4FdEKQ==", "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -1126,7 +1096,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.2.3", + "@angular/compiler": "20.3.2", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -1255,9 +1225,9 @@ } }, "node_modules/@angular/core": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.2.3.tgz", - "integrity": "sha512-pFMfg11X8SNNZHcLa+wy4y+eAN3FApt+wPzaxkaXaJ64c+tyHcrPNLotoWgE0jmiw8Idn4gGjKAL/WC0uw5dQA==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.2.tgz", + "integrity": "sha512-88uPgs5LjtnywnQaZE2ShBb1wa8IuD6jWs4nc4feo32QdBc55tjebTBFJSHbi3mUVAp0eS4wI6ITo0YIb01H4g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1266,7 +1236,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.2.3", + "@angular/compiler": "20.3.2", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -1280,9 +1250,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.2.3.tgz", - "integrity": "sha512-efMn/Hnspg91SzRTm69WpyGq0dgbCtWqUOrR0iZXTR/oDlJw9F/y/nrST36tOBwRNT0QQ2iU5z43iJY1Rl1Bng==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.2.tgz", + "integrity": "sha512-ECIbtwc7n9fPbiZXZVaoZpSiOksgcNbZ27oUN9BT7EmoXRzBw6yDL2UX6Ig7pEKhQGyBkKB+TMerRwTDVkkCWg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1291,16 +1261,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.2.3", - "@angular/core": "20.2.3", - "@angular/platform-browser": "20.2.3", + "@angular/common": "20.3.2", + "@angular/core": "20.3.2", + "@angular/platform-browser": "20.3.2", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.2.3.tgz", - "integrity": "sha512-+r7VbxuaOwUuvC1xPfuNpJSbwv4+LOUouVZhBq5sp2qYrKkVw2QZaIbd6uPTE1NWbu7rGwSGVw4rTx4LvA3fYw==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.3.2.tgz", + "integrity": "sha512-RZMHgLZV1Aka7rUKvQbg08Dn+dMyVBEGTlUS6/bTDoB1Xq2UE9L8YKmlnEDQyzveO5vTsPvZZQRL4iLc4IokzQ==", "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -1317,8 +1287,8 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.2.3", - "@angular/compiler-cli": "20.2.3" + "@angular/compiler": "20.3.2", + "@angular/compiler-cli": "20.3.2" } }, "node_modules/@angular/localize/node_modules/ansi-regex": { @@ -1441,9 +1411,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.2.3.tgz", - "integrity": "sha512-oNaRqcGUve+E/CwR9fJb8uern5rb7qNOis1bZRdPXq5rHKaWgDCxUPkoqxRi0EytorntuYsWYPUPW3ul4Ea9tw==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.2.tgz", + "integrity": "sha512-d9XcT2UuWZCc0UOtkCcPEnMcOFKNczahamT/Izg3H9jLS3IcT6l0ry23d/Xf0DRwhLYQdOZiG7l8HMZ1sWPMOg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1452,9 +1422,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.2.3", - "@angular/common": "20.2.3", - "@angular/core": "20.2.3" + "@angular/animations": "20.3.2", + "@angular/common": "20.3.2", + "@angular/core": "20.3.2" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1463,9 +1433,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.2.3.tgz", - "integrity": "sha512-uxqLv2yNibd2vf3OObyH4arVfwu+o9FKgkUcnFUdowK31emiZe1nAY8uEe/92JIsMMAoIllI/GAVzWH8dp05mg==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.2.tgz", + "integrity": "sha512-ehoV67Vxr3ZE8BJ3g7Q4ZLHo3qJVoDUDz/4UeCqmDeOnKxcdD53HTA/pgOO4QhKStUFbzgU19OQD4e6fkP8YoQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1474,16 +1444,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.2.3", - "@angular/compiler": "20.2.3", - "@angular/core": "20.2.3", - "@angular/platform-browser": "20.2.3" + "@angular/common": "20.3.2", + "@angular/compiler": "20.3.2", + "@angular/core": "20.3.2", + "@angular/platform-browser": "20.3.2" } }, "node_modules/@angular/router": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.2.3.tgz", - "integrity": "sha512-r8yGJcxHPfeIHZOoyCxN2H4nMgBD/k4TVTFaq8MHf5ryy1iLzayIMPJTFaZe7xpwlJJuBYEjBrYfUN38fYKWgA==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.2.tgz", + "integrity": "sha512-+Crx6QpK00juoNU3A1vbVf4DQ7fduLe3DUdAob6a9Uj+IoWj2Ijd8zUWF8E0cfNNFotJ4Gost0lJORDvqKcC7A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1492,9 +1462,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.2.3", - "@angular/core": "20.2.3", - "@angular/platform-browser": "20.2.3", + "@angular/common": "20.3.2", + "@angular/core": "20.3.2", + "@angular/platform-browser": "20.3.2", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -2937,6 +2907,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -2953,6 +2924,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, "engines": { "node": ">=12" }, @@ -2964,6 +2936,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "engines": { "node": ">=12" }, @@ -2974,12 +2947,14 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -2996,6 +2971,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -3010,6 +2986,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -3078,83 +3055,94 @@ } }, "node_modules/@jsverse/transloco": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.6.1.tgz", - "integrity": "sha512-hFFKJ1pVFYeW2E4UFETQpOeOn0tuncCSUdRewbq3LiV+qS9x4Z2XVuCaAaFPdiNhy4nUKHWFX1pWjpZ5XjUPaQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-8.0.2.tgz", + "integrity": "sha512-9dEErbhlWWWioW6pwKGtc5qrs1Q5NDgtFwv32J7Rv7e23+84Mw+JyLo5IGLFCS8cCR+prFmeJjn62QRMhFIDsg==", + "license": "MIT", "dependencies": { - "@jsverse/transloco-utils": "^7.0.0", - "fs-extra": "^11.0.0", - "glob": "^10.0.0", - "lodash.kebabcase": "^4.1.1", - "ora": "^5.4.1", - "replace-in-file": "^7.0.1", + "@jsverse/transloco-utils": "^8.0.2", + "@jsverse/utils": "1.0.0-beta.5", "tslib": "^2.2.0" }, "peerDependencies": { - "@angular/core": ">=16.0.0" + "@angular/core": ">=16.0.0", + "rxjs": ">=6.0.0" } }, "node_modules/@jsverse/transloco-locale": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-locale/-/transloco-locale-7.0.1.tgz", - "integrity": "sha512-mx43h2FKMKxx+Er18qArBJMxmGGW2+EShkH+xueAp+VC/ivBNQDyXWpg8hOsfNFqFQAjzlCAie1mXpbGmbM0uw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-locale/-/transloco-locale-8.0.2.tgz", + "integrity": "sha512-PKeOVkBpthmazXV8FBLSBnAFqWfvelBC2pV2rCnce+QiQG+P8oqpcNf67zUXP9fqB6gvbfAVZ/+g3UYl1qLCIA==", + "license": "MIT", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@jsverse/transloco": ">=7.0.0", + "@jsverse/transloco": ">=8.0.0", "rxjs": ">=6.0.0" } }, "node_modules/@jsverse/transloco-persist-lang": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-lang/-/transloco-persist-lang-7.0.2.tgz", - "integrity": "sha512-VPB/IbukOS64RUM0NQk2rS/wmezo8JYucYerC/94nyF50LM5tR59SyJTPHSFHBTBqXykOQSXUxLRRwzt8UrfPg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-lang/-/transloco-persist-lang-8.0.2.tgz", + "integrity": "sha512-/wQeYA1v6j1yQLIg22XeBVzUPbaFzwHla4/8FmBTpDDBcwPPlT4/+xfS9uZxv11tmhV5EMkKNwDSfaTqYfEbNw==", + "license": "MIT", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@jsverse/transloco": ">=7.0.0" + "@jsverse/transloco": ">=8.0.0", + "@jsverse/utils": "1.0.0-beta.5" } }, "node_modules/@jsverse/transloco-persist-translations": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-translations/-/transloco-persist-translations-7.0.1.tgz", - "integrity": "sha512-BUGpcD4MrIBUbo7/G06yGdkWuVTKXVESyAJp107yUbE34Ami0+4BEK7vfLTl09ARwhBQsNKIzZgTAIpzrlK98A==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-translations/-/transloco-persist-translations-8.0.2.tgz", + "integrity": "sha512-N9ENyO7UPz6CenQs1+zeRpCb/4+icRNq5eB8TQvM6UGQSkM09qVR+s83dwVEj5F8+bJMWTQxZRuQJtgc9z7KXw==", + "license": "MIT", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@jsverse/transloco": ">=7.0.0" + "@jsverse/transloco": ">=8.0.0", + "@jsverse/utils": "1.0.0-beta.5" } }, "node_modules/@jsverse/transloco-preload-langs": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-preload-langs/-/transloco-preload-langs-7.0.1.tgz", - "integrity": "sha512-J9G+r9g8UnLWsEdf0XTUhSIX/CFoKEPP6bEfyXQ7f36FFVu3raPRoEXnqE8gQGCPiyFPG0J8YSf7lyJtUHIgHA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-preload-langs/-/transloco-preload-langs-8.0.2.tgz", + "integrity": "sha512-AzT+xv0/Bzf098mCsnugYaPe9dWrTmgZ4EaPfyhfUNOY8gZdNWPDdZk0tLEUiPMqt/4Ev7ggW05b1Ae3YL8RvA==", + "license": "MIT", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@jsverse/transloco": ">=7.0.0" + "@jsverse/transloco": ">=8.0.0" } }, "node_modules/@jsverse/transloco-utils": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-utils/-/transloco-utils-7.0.2.tgz", - "integrity": "sha512-zud1M68mMC/Pu6irEba+Z2SzmwmmPzUPnBzMKlcGdIhzUe1z41cqQutK1M0QaQpY4h4yhumXcNaY/Ot6piv6QQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-utils/-/transloco-utils-8.0.2.tgz", + "integrity": "sha512-XsUBucWUxvYEbzwZeE73DwvU1kdmGt2uNzw1PfJ1bQExeBjg2DKGQexsNwvF4CguNDJnDnMSkHy8av4qUYvZ7A==", + "license": "MIT", "dependencies": { "cosmiconfig": "^8.1.3", "tslib": "^2.3.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, + "node_modules/@jsverse/utils": { + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@jsverse/utils/-/utils-1.0.0-beta.5.tgz", + "integrity": "sha512-z7IdlV6BdSeF3Veii8Yyk64KuyTjNIQnFaW5PAhmDx0wN29lB2BFp8WO6+tJPLPjtlz2yKeNrjkp1XqnMPaeHA==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz", @@ -3752,16 +3740,16 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.3.tgz", - "integrity": "sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", + "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@tybys/wasm-util": "^0.10.0" + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" } }, "node_modules/@ng-bootstrap/ng-bootstrap": { @@ -4045,20 +4033,10 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.81.0.tgz", - "integrity": "sha512-zm/LDVOq9FEmHiuM8zO4DWirv0VP2Tv2VsgaiHby9nvpq+FVrcqNYgv+TysLKOITQXWZj/roluTxFvpkHP0Iuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.81.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.81.0.tgz", - "integrity": "sha512-CnOqkybZK8z6Gx7Wb1qF7AEnSzbol1WwcIzxYOr8e91LytGOjo0wCpgoYWZo8sdbpqX+X+TJayIzo4Pv0R/KjA==", + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.89.0.tgz", + "integrity": "sha512-yuo+ECPIW5Q9mSeNmCDC2im33bfKuwW18mwkaHMQh8KakHYDzj4ci/q7wxf2qS3dMlVVCIyrs3kFtH5LmnlYnw==", "dev": true, "license": "MIT", "funding": { @@ -4401,6 +4379,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "optional": true, "engines": { "node": ">=14" @@ -4422,9 +4401,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.32.tgz", - "integrity": "sha512-Gs+313LfR4Ka3hvifdag9r44WrdKQaohya7ZXUXzARF7yx0atzFlVZjsvxtKAw1Vmtr4hB/RjUD1jf73SW7zDw==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-AE3HFQrjWCKLFZD1Vpiy+qsqTRwwoil1oM5WsKPSmfQ5fif/A+ZtOZetF32erZdsR7qyvns6qHEteEsF6g6rsQ==", "cpu": [ "arm64" ], @@ -4433,12 +4412,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.32.tgz", - "integrity": "sha512-W8oMqzGcI7wKPXUtS3WJNXzbghHfNiuM1UBAGpVb+XlUCgYRQJd2PRGP7D3WGql3rR3QEhUvSyAuCBAftPQw6Q==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-RaoWOKc0rrFsVmKOjQpebMY6c6/I7GR1FBc25v7L/R7NlM0166mUotwGEv7vxu7ruXH4SJcFeVrfADFUUXUmmQ==", "cpu": [ "arm64" ], @@ -4447,12 +4429,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.32.tgz", - "integrity": "sha512-pM4c4sKUk37noJrnnDkJknLhCsfZu7aWyfe67bD0GQHfzAPjV16wPeD9CmQg4/0vv+5IfHYaa4VE536xbA+W0Q==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.38.tgz", + "integrity": "sha512-Ymojqc2U35iUc8NFU2XX1WQPfBRRHN6xHcrxAf9WS8BFFBn8pDrH5QPvH1tYs3lDkw6UGGbanr1RGzARqdUp1g==", "cpu": [ "x64" ], @@ -4461,12 +4446,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.32.tgz", - "integrity": "sha512-M8SUgFlYb5kJJWcFC8gUMRiX4WLFxPKMed3SJ2YrxontgIrEcpizPU8nLNVsRYEStoSfKHKExpQw3OP6fm+5bw==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.38.tgz", + "integrity": "sha512-0ermTQ//WzSI0nOL3z/LUWMNiE9xeM5cLGxjewPFEexqxV/0uM8/lNp9QageQ8jfc/VO1OURsGw34HYO5PaL8w==", "cpu": [ "x64" ], @@ -4475,12 +4463,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.32.tgz", - "integrity": "sha512-FuQpbNC/hE//bvv29PFnk0AtpJzdPdYl5CMhlWPovd9g3Kc3lw9TrEPIbL7gRPUdhKAiq6rVaaGvOnXxsa0eww==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.38.tgz", + "integrity": "sha512-GADxzVUTCTp6EWI52831A29Tt7PukFe94nhg/SUsfkI33oTiNQtPxyLIT/3oRegizGuPSZSlrdBurkjDwxyEUQ==", "cpu": [ "arm" ], @@ -4489,12 +4480,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.32.tgz", - "integrity": "sha512-hRZygRlaGCjcNTNY9GV7dDI18sG1dK3cc7ujHq72LoDad23zFDUGMQjiSxHWK+/r92iMV+j2MiHbvzayxqynsg==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.38.tgz", + "integrity": "sha512-SKO7Exl5Yem/OSNoA5uLHzyrptUQ8Hg70kHDxuwEaH0+GUg+SQe9/7PWmc4hFKBMrJGdQtii8WZ0uIz9Dofg5Q==", "cpu": [ "arm64" ], @@ -4503,12 +4497,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.32.tgz", - "integrity": "sha512-HzgT6h+CXLs+GKAU0Wvkt3rvcv0CmDBsDjlPhh4GHysOKbG9NjpKYX2zvjx671E9pGbTvcPpwy7gGsy7xpu+8g==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.38.tgz", + "integrity": "sha512-SOo6+WqhXPBaShLxLT0eCgH17d3Yu1lMAe4mFP0M9Bvr/kfMSOPQXuLxBcbBU9IFM9w3N6qP9xWOHO+oUJvi8Q==", "cpu": [ "arm64" ], @@ -4517,12 +4514,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.32.tgz", - "integrity": "sha512-Ab/wbf6gdzphDbsg51UaxsC93foQ7wxhtg0SVCXd25BrV4MAJ1HoDtKN/f4h0maFmJobkqYub2DlmoasUzkvBg==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.38.tgz", + "integrity": "sha512-yvsQ3CyrodOX+lcoi+lejZGCOvJZa9xTsNB8OzpMDmHeZq3QzJfpYjXSAS6vie70fOkLVJb77UqYO193Cl8XBQ==", "cpu": [ "x64" ], @@ -4531,12 +4531,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.32.tgz", - "integrity": "sha512-VoxqGEfh5A1Yx+zBp/FR5QwAbtzbuvky2SVc+ii4g1gLD4zww6mt/hPi5zG+b88zYPFBKHpxMtsz9cWqXU5V5Q==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.38.tgz", + "integrity": "sha512-84qzKMwUwikfYeOuJ4Kxm/3z15rt0nFGGQArHYIQQNSTiQdxGHxOkqXtzPFqrVfBJUdxBAf+jYzR1pttFJuWyg==", "cpu": [ "x64" ], @@ -4545,12 +4548,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.32.tgz", - "integrity": "sha512-qZ1ViyOUDGbiZrSAJ/FIAhYUElDfVxxFW6DLT/w4KeoZN3HsF4jmRP95mXtl51/oGrqzU9l9Q2f7/P4O/o2ZZA==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.38.tgz", + "integrity": "sha512-QrNiWlce01DYH0rL8K3yUBu+lNzY+B0DyCbIc2Atan6/S6flxOL0ow5DLQvMamOI/oKhrJ4xG+9MkMb9dDHbLQ==", "cpu": [ "arm64" ], @@ -4559,12 +4565,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.32.tgz", - "integrity": "sha512-hEkG3wD+f3wytV0lqwb/uCrXc4r4Ny/DWJFJPfQR3VeMWplhWGgSHNwZc2Q7k86Yi36f9NNzzWmrIuvHI9lCVw==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.38.tgz", + "integrity": "sha512-fnLtHyjwEsG4/aNV3Uv3Qd1ZbdH+CopwJNoV0RgBqrcQB8V6/Qdikd5JKvnO23kb3QvIpP+dAMGZMv1c2PJMzw==", "cpu": [ "wasm32" ], @@ -4572,16 +4581,16 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.3" + "@napi-rs/wasm-runtime": "^1.0.5" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.32.tgz", - "integrity": "sha512-k3MvDf8SiA7uP2ikP0unNouJ2YCrnwi7xcVW+RDgMp5YXVr3Xu6svmT3HGn0tkCKUuPmf+uy8I5uiHt5qWQbew==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-19cTfnGedem+RY+znA9J6ARBOCEFD4YSjnx0p5jiTm9tR6pHafRfFIfKlTXhun+NL0WWM/M0eb2IfPPYUa8+wg==", "cpu": [ "arm64" ], @@ -4590,12 +4599,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.32.tgz", - "integrity": "sha512-wAi/FxGh7arDOUG45UmnXE1sZUa0hY4cXAO2qWAjFa3f7bTgz/BqwJ7XN5SUezvAJPNkME4fEpInfnBvM25a0w==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-HcICm4YzFJZV+fI0O0bFLVVlsWvRNo/AB9EfUXvNYbtAxakCnQZ15oq22deFdz6sfi9Y4/SagH2kPU723dhCFA==", "cpu": [ "ia32" ], @@ -4604,12 +4616,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.32.tgz", - "integrity": "sha512-Ej0i4PZk8ltblZtzVK8ouaGUacUtxRmTm5S9794mdyU/tYxXjAJNseOfxrnHpMWKjMDrOKbqkPqJ52T9NR4LQQ==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.38.tgz", + "integrity": "sha512-4Qx6cgEPXLb0XsCyLoQcUgYBpfL0sjugftob+zhUH0EOk/NVCAIT+h0NJhY+jn7pFpeKxhNMqhvTNx3AesxIAQ==", "cpu": [ "x64" ], @@ -4618,12 +4633,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", - "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", "dev": true, "license": "MIT" }, @@ -4908,14 +4926,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "20.2.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.2.1.tgz", - "integrity": "sha512-7Vx11KWooiqxP206JEVgz3cp0rRv31PYnocNoPM6UqLhGtlvL9GdgaZHzDhGFEm0hv6DUFrbTGIzB89gXc54Xg==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.3.tgz", + "integrity": "sha512-lqIP1pNKp8yaqd663R3graZWaTBjXH+Cl72BQl1Ghl7lFGReZJALr4GiSMiBR9r30Epklcw5TwOSi+Bs4UKmbw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.2.1", - "@angular-devkit/schematics": "20.2.1", + "@angular-devkit/core": "20.3.3", + "@angular-devkit/schematics": "20.3.3", "jsonc-parser": "3.3.1" }, "engines": { @@ -5106,9 +5124,9 @@ "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -5501,16 +5519,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz", - "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", + "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.42.0", - "@typescript-eslint/types": "8.42.0", - "@typescript-eslint/typescript-estree": "8.42.0", - "@typescript-eslint/visitor-keys": "8.42.0", + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4" }, "engines": { @@ -5526,14 +5544,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", - "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", + "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.42.0", - "@typescript-eslint/types": "^8.42.0", + "@typescript-eslint/tsconfig-utils": "^8.44.1", + "@typescript-eslint/types": "^8.44.1", "debug": "^4.3.4" }, "engines": { @@ -5548,14 +5566,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz", - "integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", + "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.42.0", - "@typescript-eslint/visitor-keys": "8.42.0" + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5566,9 +5584,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", - "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", + "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", "dev": true, "license": "MIT", "engines": { @@ -5583,9 +5601,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", - "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", + "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", "dev": true, "license": "MIT", "engines": { @@ -5597,16 +5615,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", - "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", + "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.42.0", - "@typescript-eslint/tsconfig-utils": "8.42.0", - "@typescript-eslint/types": "8.42.0", - "@typescript-eslint/visitor-keys": "8.42.0", + "@typescript-eslint/project-service": "8.44.1", + "@typescript-eslint/tsconfig-utils": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5626,13 +5644,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.42.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", - "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", + "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6015,6 +6033,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -6023,6 +6042,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -6075,26 +6095,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/beasties": { "version": "0.3.5", @@ -6116,16 +6118,6 @@ "node": ">=14.0.0" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "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", @@ -6176,6 +6168,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -6223,29 +6216,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6409,6 +6379,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6458,21 +6429,11 @@ "node": ">=10" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6562,47 +6523,11 @@ "node": ">= 12" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -6613,7 +6538,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -6694,6 +6620,7 @@ "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -6725,6 +6652,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7007,17 +6935,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7144,7 +7061,8 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true }, "node_modules/ee-first": { "version": "1.1.1", @@ -7161,7 +7079,8 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -7226,9 +7145,10 @@ "license": "MIT" }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } @@ -7793,10 +7713,13 @@ } }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -7885,6 +7808,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -7916,19 +7840,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -7942,11 +7853,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8042,6 +7948,7 @@ "version": "10.3.12", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", @@ -8094,7 +8001,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/gradient-path": { "version": "2.3.0", @@ -8129,6 +8037,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -8291,25 +8200,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8371,19 +8261,11 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/ini": { "version": "5.0.0", @@ -8430,7 +8312,8 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", @@ -8460,6 +8343,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -8476,14 +8360,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "engines": { - "node": ">=8" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8500,21 +8376,11 @@ "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", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -8604,6 +8470,7 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -8697,17 +8564,6 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonminify": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/jsonminify/-/jsonminify-0.4.2.tgz", @@ -8789,6 +8645,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/listr2": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", @@ -8942,32 +8804,12 @@ "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", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -9332,14 +9174,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -9357,6 +9191,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -9371,6 +9206,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -9736,9 +9572,10 @@ } }, "node_modules/ngx-toastr": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.0.0.tgz", - "integrity": "sha512-6pTnktwwWD+kx342wuMOWB4+bkyX9221pAgGz3SHOJH0/MI9erLucS8PeeJDFwbUYyh75nQ6AzVtolgHxi52dQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.1.0.tgz", + "integrity": "sha512-Qa7Kg7QzGKNtp1v04hu3poPKKx8BGBD/Onkhm6CdH5F0vSMdq+BdR/f8DTpZnGFksW891tAFufpiWb9UZX+3vg==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, @@ -10076,24 +9913,11 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "dependencies": { "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -10120,28 +9944,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ordered-binary": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", @@ -10217,6 +10019,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -10233,12 +10036,8 @@ "node_modules/parse-json/node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/parse-json/node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" }, "node_modules/parse5": { "version": "8.0.0", @@ -10328,6 +10127,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -10342,6 +10142,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", + "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -10357,6 +10158,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "dev": true, "engines": { "node": "14 || >=16.14" } @@ -10376,6 +10178,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", "engines": { "node": ">=8" } @@ -10614,19 +10417,6 @@ "node": ">= 0.8" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -10645,59 +10435,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/replace-in-file": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-7.1.0.tgz", - "integrity": "sha512-1uZmJ78WtqNYCSuPC9IWbweXkGxPOtk2rKuar8diTw7naVIQZiE3Tm8ACx2PCMXDtVH6N+XxwaRY2qZ2xHPqXw==", - "dependencies": { - "chalk": "^4.1.2", - "glob": "^8.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "replace-in-file": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/replace-in-file/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/replace-in-file/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10732,23 +10469,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -10777,35 +10497,37 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-beta.32", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.32.tgz", - "integrity": "sha512-vxI2sPN07MMaoYKlFrVva5qZ1Y7DAZkgp7MQwTnyHt4FUMz9Sh+YeCzNFV9JYHI6ZNwoGWLCfCViE3XVsRC1cg==", + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.38.tgz", + "integrity": "sha512-58frPNX55Je1YsyrtPJv9rOSR3G5efUZpRqok94Efsj0EUa8dnqJV3BldShyI7A+bVPleucOtzXHwVpJRcR0kQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "=0.81.0", - "@oxc-project/types": "=0.81.0", - "@rolldown/pluginutils": "1.0.0-beta.32", + "@oxc-project/types": "=0.89.0", + "@rolldown/pluginutils": "1.0.0-beta.38", "ansis": "^4.0.0" }, "bin": { "rolldown": "bin/cli.mjs" }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.32", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.32", - "@rolldown/binding-darwin-x64": "1.0.0-beta.32", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.32", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.32", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.32", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.32", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.32", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.32", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.32", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.32", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.32", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.32", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.32" + "@rolldown/binding-android-arm64": "1.0.0-beta.38", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.38", + "@rolldown/binding-darwin-x64": "1.0.0-beta.38", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.38", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.38", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.38", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.38", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.38", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.38", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.38", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.38", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.38", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.38", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.38" } }, "node_modules/rollup": { @@ -10900,6 +10622,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -11020,6 +10743,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -11031,6 +10755,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -11115,6 +10840,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "engines": { "node": ">=14" }, @@ -11356,18 +11082,11 @@ "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", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -11382,6 +11101,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -11395,6 +11115,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11407,6 +11128,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11430,6 +11152,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11806,14 +11529,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11871,11 +11586,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -11914,18 +11624,18 @@ } }, "node_modules/vite": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", - "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", + "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", - "tinyglobby": "^0.2.14" + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -11988,6 +11698,23 @@ } } }, + "node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -12002,14 +11729,6 @@ "node": ">=10.13.0" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/weak-lru-cache": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", @@ -12083,6 +11802,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -12113,6 +11833,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -12128,7 +11849,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/ws": { "version": "7.5.10", @@ -12164,31 +11886,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "engines": { - "node": ">=12" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 4a4708e33..5987429a3 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -20,25 +20,25 @@ "private": true, "dependencies": { "@angular-slider/ngx-slider": "^20.0.0", - "@angular/animations": "^20.2.3", - "@angular/cdk": "^20.2.1", - "@angular/common": "^20.2.3", - "@angular/compiler": "^20.2.3", - "@angular/core": "^20.2.3", - "@angular/forms": "^20.2.3", - "@angular/localize": "^20.2.3", - "@angular/platform-browser": "^20.2.3", - "@angular/platform-browser-dynamic": "^20.2.3", - "@angular/router": "^20.2.3", + "@angular/animations": "^20.3.2", + "@angular/cdk": "^20.2.5", + "@angular/common": "^20.3.2", + "@angular/compiler": "^20.3.2", + "@angular/core": "^20.3.2", + "@angular/forms": "^20.3.2", + "@angular/localize": "^20.3.2", + "@angular/platform-browser": "^20.3.2", + "@angular/platform-browser-dynamic": "^20.3.2", + "@angular/router": "^20.3.2", "@fortawesome/fontawesome-free": "^7.0.1", "@iharbeck/ngx-virtual-scroller": "^19.0.1", "@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", + "@jsverse/transloco": "^8.0.2", + "@jsverse/transloco-locale": "^8.0.2", + "@jsverse/transloco-persist-lang": "^8.0.2", + "@jsverse/transloco-persist-translations": "^8.0.2", + "@jsverse/transloco-preload-langs": "^8.0.2", "@microsoft/signalr": "^9.0.6", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.7", @@ -57,7 +57,7 @@ "ngx-file-drop": "^16.0.0", "ngx-quill": "^28.0.1", "ngx-stars": "^1.6.5", - "ngx-toastr": "^19.0.0", + "ngx-toastr": "^19.1.0", "nosleep.js": "^0.12.0", "rxjs": "^7.8.2", "screenfull": "^6.0.2", @@ -66,21 +66,21 @@ "zone.js": "^0.15.1" }, "devDependencies": { - "@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.2.1", - "@angular/cli": "^20.2.1", - "@angular/compiler-cli": "^20.2.3", + "@angular-eslint/builder": "^20.3.0", + "@angular-eslint/eslint-plugin": "^20.3.0", + "@angular-eslint/eslint-plugin-template": "^20.3.0", + "@angular-eslint/schematics": "^20.3.0", + "@angular-eslint/template-parser": "^20.3.0", + "@angular/build": "^20.3.3", + "@angular/cli": "^20.3.3", + "@angular/compiler-cli": "^20.3.2", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", "@types/luxon": "^3.6.2", "@types/marked": "^5.0.2", "@types/node": "^24.0.14", "@typescript-eslint/eslint-plugin": "^8.38.0", - "@typescript-eslint/parser": "^8.42.0", + "@typescript-eslint/parser": "^8.44.1", "eslint": "^9.31.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", diff --git a/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts b/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts new file mode 100644 index 000000000..dd2dab31b --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts @@ -0,0 +1,28 @@ +import {FilterV2} from "./filter-v2"; + + +export enum AnnotationsFilterField { + Owner = 1, + Library = 2, + Spoiler = 3, + HighlightSlots = 4, + Selection = 5, + Comment = 6, +} + +export const allAnnotationsFilterFields = Object.keys(AnnotationsFilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as AnnotationsFilterField[]; + +export enum AnnotationsSortField { + Owner = 1, + Created = 2, + LastModified = 3, + Color = 4, +} + +export const allAnnotationsSortFields = Object.keys(AnnotationsSortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as AnnotationsSortField[]; + +export type AnnotationsFilter = FilterV2; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index eeb8c7853..c89959046 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -35,7 +35,8 @@ export enum FilterField Imprint = 29, Team = 30, Location = 31, - ReadLast = 32 + ReadLast = 32, + FileSize = 33, } diff --git a/UI/Web/src/app/_models/preferences/reading-profiles.ts b/UI/Web/src/app/_models/preferences/reading-profiles.ts index 86fdadfe0..0161deb10 100644 --- a/UI/Web/src/app/_models/preferences/reading-profiles.ts +++ b/UI/Web/src/app/_models/preferences/reading-profiles.ts @@ -11,7 +11,6 @@ import {PdfScrollMode} from "./pdf-scroll-mode"; import {PdfLayoutMode} from "./pdf-layout-mode"; import {PdfSpreadMode} from "./pdf-spread-mode"; import {UserBreakpoint} from "../../shared/_services/utility.service"; -import {EpubPageCalculationMethod} from "../readers/epub-page-calculation-method"; export enum ReadingProfileKind { Default = 0, @@ -52,7 +51,6 @@ export interface ReadingProfile { bookReaderThemeName: string; bookReaderLayoutMode: BookPageLayoutMode; bookReaderImmersiveMode: boolean; - bookReaderEpubPageCalculationMethod: EpubPageCalculationMethod; // PDF Reader pdfTheme: PdfTheme; diff --git a/UI/Web/src/app/_models/readers/epub-page-calculation-method.ts b/UI/Web/src/app/_models/readers/epub-page-calculation-method.ts deleted file mode 100644 index f9769f0b4..000000000 --- a/UI/Web/src/app/_models/readers/epub-page-calculation-method.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum EpubPageCalculationMethod { - Default = 0, - Calculation1 = 1 -} - -export const allCalcMethods = [EpubPageCalculationMethod.Default, EpubPageCalculationMethod.Calculation1]; diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 150e9768c..d99894f88 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -3,6 +3,7 @@ import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { + id: number; username: string; token: string; refreshToken: string; diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts index 0495e8b8a..0451353fb 100644 --- a/UI/Web/src/app/_pipes/browse-title.pipe.ts +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -70,6 +70,7 @@ export class BrowseTitlePipe implements PipeTransform { case FilterField.ReadLast: case FilterField.Summary: case FilterField.SeriesName: + case FilterField.FileSize: default: return ''; } diff --git a/UI/Web/src/app/_pipes/epub-page-calc-method.pipe.ts b/UI/Web/src/app/_pipes/epub-page-calc-method.pipe.ts deleted file mode 100644 index 50dcb5918..000000000 --- a/UI/Web/src/app/_pipes/epub-page-calc-method.pipe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {Pipe, PipeTransform} from '@angular/core'; -import {EpubPageCalculationMethod} from "../_models/readers/epub-page-calculation-method"; -import {translate} from "@jsverse/transloco"; - -@Pipe({ - name: 'epubPageCalcMethod' -}) -export class EpubPageCalcMethodPipe implements PipeTransform { - - transform(value: EpubPageCalculationMethod) { - switch (value) { - case EpubPageCalculationMethod.Default: - return translate('epub-page-calc-method-pipe.default'); - case EpubPageCalculationMethod.Calculation1: - return translate('epub-page-calc-method-pipe.calc1'); - - } - } - -} diff --git a/UI/Web/src/app/_pipes/filter-field.pipe.ts b/UI/Web/src/app/_pipes/filter-field.pipe.ts index 056d99f53..cb9f13d11 100644 --- a/UI/Web/src/app/_pipes/filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-field.pipe.ts @@ -76,6 +76,8 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.read-last'); case FilterField.AverageRating: return translate('filter-field-pipe.average-rating'); + case FilterField.FileSize: + return translate('filter-field-pipe.file-size'); default: throw new Error(`Invalid FilterField value: ${value}`); } diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts index f342c0034..691d546a1 100644 --- a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -3,6 +3,7 @@ import {FilterField} from "../_models/metadata/v2/filter-field"; import {translate} from "@jsverse/transloco"; import {ValidFilterEntity} from "../metadata-filter/filter-settings"; import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {AnnotationsFilterField} from "../_models/metadata/v2/annotations-filter"; @Pipe({ name: 'genericFilterField' @@ -12,6 +13,8 @@ export class GenericFilterFieldPipe implements PipeTransform { transform(value: T, entityType: ValidFilterEntity): string { switch (entityType) { + case "annotation": + return this.annotationsFilterField(value as AnnotationsFilterField); case "series": return this.translateFilterField(value as FilterField); case "person": @@ -19,6 +22,24 @@ export class GenericFilterFieldPipe implements PipeTransform { } } + private annotationsFilterField(value: AnnotationsFilterField) { + switch (value) { + case AnnotationsFilterField.Selection: + return translate('generic-filter-field-pipe.annotation-selection') + case AnnotationsFilterField.Comment: + return translate('generic-filter-field-pipe.annotation-comment') + case AnnotationsFilterField.HighlightSlots: + return translate('generic-filter-field-pipe.annotation-highlights') + case AnnotationsFilterField.Owner: + return translate('generic-filter-field-pipe.annotation-owner'); + case AnnotationsFilterField.Library: + return translate('filter-field-pipe.libraries'); + case AnnotationsFilterField.Spoiler: + return translate('generic-filter-field-pipe.annotation-spoiler'); + } + } + + private translatePersonFilterField(value: PersonFilterField) { switch (value) { case PersonFilterField.Role: @@ -100,6 +121,8 @@ export class GenericFilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.read-last'); case FilterField.AverageRating: return translate('filter-field-pipe.average-rating'); + case FilterField.FileSize: + return translate('filter-field-pipe.file-size'); default: throw new Error(`Invalid FilterField value: ${value}`); } diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index c2464935d..62f5401b5 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -3,6 +3,7 @@ import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; import {ValidFilterEntity} from "../metadata-filter/filter-settings"; import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; +import {AnnotationsSortField} from "../_models/metadata/v2/annotations-filter"; @Pipe({ name: 'sortField', @@ -15,6 +16,8 @@ export class SortFieldPipe implements PipeTransform { transform(value: T, entityType: ValidFilterEntity): string { switch (entityType) { + case "annotation": + return this.getAnnotationSortFields(value as AnnotationsSortField); case 'series': return this.seriesSortFields(value as SortField); case 'person': @@ -23,6 +26,19 @@ export class SortFieldPipe implements PipeTransform { } } + private getAnnotationSortFields(value: AnnotationsSortField) { + switch (value) { + case AnnotationsSortField.Color: + return this.translocoService.translate('sort-field-pipe.annotation-color'); + case AnnotationsSortField.LastModified: + return this.translocoService.translate('sort-field-pipe.last-modified'); + case AnnotationsSortField.Owner: + return this.translocoService.translate('sort-field-pipe.annotation-owner'); + case AnnotationsSortField.Created: + return this.translocoService.translate('sort-field-pipe.created'); + } + } + private personSortFields(value: PersonSortField) { switch (value) { case PersonSortField.Name: diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts index be96e8193..ae5022bd1 100644 --- a/UI/Web/src/app/_routes/browse-routing.module.ts +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -3,6 +3,7 @@ import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.compo import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; +import {AllAnnotationsComponent} from "../all-annotations/all-annotations.component"; export const routes: Routes = [ @@ -21,4 +22,10 @@ export const routes: Routes = [ }, {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, + {path: 'annotations', component: AllAnnotationsComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver, + }, + runGuardsAndResolvers: 'always' + } ]; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 4d0cf9384..bd2d733d2 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -14,6 +14,7 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; import {Person} from "../_models/metadata/person"; import {User} from '../_models/user'; +import {Annotation} from "../book-reader/_models/annotations/annotation"; export enum Action { Submenu = -1, @@ -130,6 +131,7 @@ export enum Action { * Remove the reading profile from the entity */ ClearReadingProfile = 31, + Export = 32, } /** @@ -150,7 +152,7 @@ export interface ActionItem { /** * @deprecated Use required Roles instead */ - requiresAdmin: boolean; + requiresAdmin?: boolean; children: Array>; /** * An optional class which applies to an item. ie) danger on a delete action @@ -194,6 +196,7 @@ export class ActionFactoryService { private sideNavStreamActions: Array> = []; private smartFilterActions: Array> = []; private sideNavHomeActions: Array> = []; + private annotationActions: Array> = []; constructor() { this.accountService.currentUser$.subscribe((_) => { @@ -245,6 +248,10 @@ export class ActionFactoryService { return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); } + getAnnotationActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.annotationActions, callback, shouldRenderFunc); + } + dummyCallback(action: ActionItem, entity: any) {} dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} basicReadRender(action: ActionItem, entity: any, user: User) { @@ -1099,7 +1106,28 @@ export class ActionFactoryService { requiredRoles: [], children: [], } - ] + ]; + + this.annotationActions = [ + { + action: Action.Delete, + title: 'delete', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiredRoles: [], + children: [], + }, + { + action: Action.Export, + title: 'export', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiredRoles: [], + children: [], + } + ]; } @@ -1118,9 +1146,9 @@ export class ActionFactoryService { }); } - public applyCallbackToList(list: Array>, - callback: ActionCallback, - shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { + public applyCallbackToList(list: Array>, + callback: ActionCallback, + shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { // Create a clone of the list to ensure we aren't affecting the default state const actions = list.map((a) => { return { ...a }; diff --git a/UI/Web/src/app/_services/annotation.service.ts b/UI/Web/src/app/_services/annotation.service.ts index d9fb3c9f7..8cf857e2b 100644 --- a/UI/Web/src/app/_services/annotation.service.ts +++ b/UI/Web/src/app/_services/annotation.service.ts @@ -1,15 +1,22 @@ import {computed, inject, Injectable, signal} from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; +import {HttpClient, HttpParams} 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 {asyncScheduler, map, of, tap} from "rxjs"; +import {switchMap, throttleTime} 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"; +import {SAVER, Saver} from "../_providers/saver.provider"; +import {download} from "../shared/_models/download"; +import {DEBOUNCE_TIME} from "../shared/_services/download.service"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {AnnotationsFilterField, AnnotationsSortField} from "../_models/metadata/v2/annotations-filter"; +import {UtilityService} from "../shared/_services/utility.service"; +import {PaginatedResult} from "../_models/pagination"; /** * Represents any modification (create/delete/edit) that occurs to annotations @@ -28,9 +35,11 @@ export class AnnotationService { private readonly httpClient = inject(HttpClient); private readonly accountService = inject(AccountService); + private readonly utilityService = inject(UtilityService); private readonly messageHub = inject(MessageHubService); private readonly router = inject(Router); private readonly baseUrl = environment.apiUrl; + private readonly save = inject(SAVER); private _annotations = signal([]); /** @@ -73,6 +82,16 @@ export class AnnotationService { })); } + getAllAnnotationsFiltered(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { + const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage); + + return this.httpClient.post[]>(this.baseUrl + 'annotation/all-filtered', filter, {observe: 'response', params}).pipe( + map((res: any) => { + return this.utilityService.createPaginatedResult(res as PaginatedResult[]); + }), + ); + } + getAnnotationsForSeries(seriesId: number) { return this.httpClient.get>(this.baseUrl + 'annotation/all-for-series?seriesId=' + seriesId); } @@ -109,23 +128,47 @@ export class AnnotationService { 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]; + /** + * Deletes an annotation without it needing to be loading in the signal. + * Used in the ViewEditAnnotationDrawer. Event is still fired. + * @param annotation + */ + deleteAnnotation(annotation: Annotation) { + const id = annotation.id; 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, + pageNumber: annotation.pageNumber, type: 'delete', - annotation: annotationToDelete + annotation: annotation }); })); } + delete(id: number) { + const filtered = this.annotations().filter(a => a.id === id); + if (filtered.length === 0) return of(); + const annotationToDelete = filtered[0]; + + return this.deleteAnnotation(annotationToDelete); + } + + /** + * While this method will update the services annotations list. No events will be sent out. + * Deletion on the callers' side should be handled in the rxjs chain. + * @param ids + */ + bulkDelete(ids: number[]) { + return this.httpClient.post(this.baseUrl + "annotation/bulk-delete", ids).pipe( + tap(() => { + this._annotations.update(x => x.filter(a => !ids.includes(a.id))); + }), + ); + } + /** * Routes to the book reader with the annotation in view * @param item @@ -133,4 +176,29 @@ export class AnnotationService { navigateToAnnotation(item: Annotation) { this.router.navigate(['/library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], { queryParams: { annotation: item.id } }); } + + exportFilter(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { + const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage); + + return this.httpClient.post(this.baseUrl + 'annotation/export-filter', filter, { + observe: 'events', + responseType: 'blob', + reportProgress: true, + params}). + pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }) + ); + } + + exportAnnotations(ids?: number[]) { + return this.httpClient.post(this.baseUrl + 'annotation/export', ids, {observe: 'events', responseType: 'blob', reportProgress: true}).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }) + ); + } } diff --git a/UI/Web/src/app/_services/epub-reader-menu.service.ts b/UI/Web/src/app/_services/epub-reader-menu.service.ts index 529b91386..1ffa0c4c1 100644 --- a/UI/Web/src/app/_services/epub-reader-menu.service.ts +++ b/UI/Web/src/app/_services/epub-reader-menu.service.ts @@ -162,10 +162,6 @@ export class EpubReaderMenuService { 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); diff --git a/UI/Web/src/app/_services/epub-reader-settings.service.ts b/UI/Web/src/app/_services/epub-reader-settings.service.ts index b24c74f91..2e25d520e 100644 --- a/UI/Web/src/app/_services/epub-reader-settings.service.ts +++ b/UI/Web/src/app/_services/epub-reader-settings.service.ts @@ -18,7 +18,6 @@ import {UserBreakpoint, UtilityService} from "../shared/_services/utility.servic import {environment} from "../../environments/environment"; import {EpubFont} from "../_models/preferences/epub-font"; import {FontService} from "./font.service"; -import {EpubPageCalculationMethod} from "../_models/readers/epub-page-calculation-method"; export interface ReaderSettingUpdate { setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme' | 'pageCalcMethod'; @@ -36,7 +35,6 @@ export type BookReadingProfileFormGroup = FormGroup<{ bookReaderThemeName: FormControl; bookReaderLayoutMode: FormControl; bookReaderImmersiveMode: FormControl; - bookReaderEpubPageCalculationMethod: FormControl; }> @Injectable() @@ -64,7 +62,6 @@ export class EpubReaderSettingsService { private readonly _activeTheme = signal(undefined); private readonly _clickToPaginate = signal(false); private readonly _layoutMode = signal(BookPageLayoutMode.Default); - private readonly _pageCalcMode = signal(EpubPageCalculationMethod.Default); private readonly _immersiveMode = signal(false); private readonly _isFullscreen = signal(false); @@ -89,7 +86,6 @@ export class EpubReaderSettingsService { public readonly immersiveMode = this._immersiveMode.asReadonly(); public readonly isFullscreen = this._isFullscreen.asReadonly(); public readonly epubFonts = this._epubFonts.asReadonly(); - public readonly pageCalcMode = this._pageCalcMode.asReadonly(); // Computed signals for derived state public readonly layoutMode = computed(() => { @@ -209,18 +205,6 @@ export class EpubReaderSettingsService { }); } }); - - effect(() => { - const pageCalcMethod = this._pageCalcMode(); - if (!this.isInitialized) return; - - if (pageCalcMethod) { - this.settingUpdateSubject.next({ - setting: 'pageCalcMethod', - object: pageCalcMethod - }); - } - }); } @@ -284,9 +268,6 @@ export class EpubReaderSettingsService { if (profile.bookReaderLayoutMode === undefined) { profile.bookReaderLayoutMode = BookPageLayoutMode.Default; } - if (profile.bookReaderEpubPageCalculationMethod === undefined) { - profile.bookReaderEpubPageCalculationMethod = EpubPageCalculationMethod.Default; - } // Update signals from profile this._readingDirection.set(profile.bookReaderReadingDirection); @@ -294,7 +275,6 @@ export class EpubReaderSettingsService { this._clickToPaginate.set(profile.bookReaderTapToPaginate); this._layoutMode.set(profile.bookReaderLayoutMode); this._immersiveMode.set(profile.bookReaderImmersiveMode); - this._pageCalcMode.set(profile.bookReaderEpubPageCalculationMethod); // Set up page styles this.setPageStyles( @@ -393,11 +373,6 @@ export class EpubReaderSettingsService { this.settingsForm.get('bookReaderWritingStyle')?.setValue(value); } - updatePageCalcMethod(value: EpubPageCalculationMethod) { - this._pageCalcMode.set(value); - this.settingsForm.get('bookReaderEpubPageCalculationMethod')?.setValue(value); - } - updateFullscreen(value: boolean) { this._isFullscreen.set(value); if (!this._isInitialized()) return; @@ -492,7 +467,6 @@ export class EpubReaderSettingsService { bookReaderThemeName: this.fb.control(profile.bookReaderThemeName), bookReaderLayoutMode: this.fb.control(this._layoutMode()), bookReaderImmersiveMode: this.fb.control(this._immersiveMode()), - bookReaderEpubPageCalculationMethod: this.fb.control(this._pageCalcMode()) }); // Set up value change subscriptions @@ -607,14 +581,6 @@ export class EpubReaderSettingsService { this.isUpdatingFromForm = false; }); - // Page Calc Method - this.settingsForm.get('bookReaderEpubPageCalculationMethod')?.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe(value => { - this.isUpdatingFromForm = true; - this._pageCalcMode.set(value as EpubPageCalculationMethod); - this.isUpdatingFromForm = false; - }); // Update implicit profile on form changes (debounced) - ONLY source of profile updates this.settingsForm.valueChanges.pipe( @@ -678,7 +644,6 @@ export class EpubReaderSettingsService { data.bookReaderImmersiveMode = this._immersiveMode(); data.bookReaderReadingDirection = this._readingDirection(); data.bookReaderWritingStyle = this._writingStyle(); - data.bookReaderEpubPageCalculationMethod = this._pageCalcMode(); const activeTheme = this._activeTheme(); if (activeTheme) { diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts index 59ab91457..0ceda09ae 100644 --- a/UI/Web/src/app/_services/license.service.ts +++ b/UI/Web/src/app/_services/license.service.ts @@ -42,6 +42,10 @@ export class LicenseService { return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); } + resendLicense() { + return this.httpClient.post(this.baseUrl + 'license/resend-license', {}, TextResonse).pipe(map(res => (res + '') === "true")); + } + /** * Returns information about License and will internally cache if license is valid or not */ diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index acbfe37bc..598cb4138 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,7 +1,7 @@ import {HttpClient, HttpParams} from '@angular/common/http'; -import {inject, Injectable} from '@angular/core'; +import {computed, inject, Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; -import {map, of} from 'rxjs'; +import {map, Observable, of} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; @@ -22,7 +22,7 @@ import {TextResonse} from "../_types/text-response"; import {QueryContext} from "../_models/metadata/v2/query-context"; import {AgeRatingPipe} from "../_pipes/age-rating.pipe"; import {MangaFormatPipe} from "../_pipes/manga-format.pipe"; -import {TranslocoService} from "@jsverse/transloco"; +import {translate, TranslocoService} from "@jsverse/transloco"; import {LibraryService} from './library.service'; import {CollectionTagService} from "./collection-tag.service"; import {PaginatedResult} from "../_models/pagination"; @@ -33,6 +33,10 @@ import {ValidFilterEntity} from "../metadata-filter/filter-settings"; import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; +import {AnnotationsFilterField} from "../_models/metadata/v2/annotations-filter"; +import {AccountService} from "./account.service"; +import {MemberService} from "./member.service"; +import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot"; @Injectable({ providedIn: 'root' @@ -45,6 +49,12 @@ export class MetadataService { private readonly libraryService = inject(LibraryService); private readonly collectionTagService = inject(CollectionTagService); private readonly utilityService = inject(UtilityService); + private readonly accountService = inject(AccountService); + private readonly memberService = inject(MemberService) + + private readonly highlightSlots = computed(() => { + return this.accountService.currentUserSignal()?.preferences?.bookReaderHighlightSlots ?? []; + }); baseUrl = environment.apiUrl; private validLanguages: Array = []; @@ -167,6 +177,12 @@ export class MetadataService { createDefaultFilterStatement(entityType: ValidFilterEntity) { switch (entityType) { + case "annotation": + const userId = this.accountService.currentUserSignal()?.id; + if (userId) { + return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUserSignal()!.id}`); + } + return this.createFilterStatement(AnnotationsFilterField.Owner); case 'series': return this.createFilterStatement(FilterField.SeriesName); case 'person': @@ -240,8 +256,9 @@ export class MetadataService { * @param entityType */ getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { - switch (entityType) { + case "annotation": + return this.getAnnotationOptionsForFilterField(filterField as AnnotationsFilterField); case 'series': return this.getSeriesOptionsForFilterField(filterField as FilterField); case 'person': @@ -249,6 +266,25 @@ export class MetadataService { } } + private getAnnotationOptionsForFilterField(field: AnnotationsFilterField): Observable<{value: number, label: string, color?: RgbaColor}[]> { + switch (field) { + case AnnotationsFilterField.Owner: + return this.memberService.getMembers(false).pipe(map(members => members.map(member => { + return {value: member.id, label: member.username}; + }))); + case AnnotationsFilterField.Library: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, label: lib.name}; + }))); + case AnnotationsFilterField.HighlightSlots: + return of(this.highlightSlots().map((slot, idx) => { + return {value: slot.slotNumber, label: translate('highlight-bar.slot-label', {slot: slot.slotNumber + 1}), color: slot.color}; // Slots start at 0 + })); + } + + return of([]); + } + private getPersonOptionsForFilterField(field: PersonFilterField) { switch (field) { case PersonFilterField.Role: diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 4e48527ea..f4a3ca4c2 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,5 +1,5 @@ import {DOCUMENT} from '@angular/common'; -import { DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2 } from '@angular/core'; +import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; import {filter, ReplaySubject, take} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; @@ -9,10 +9,8 @@ import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; -import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; import {WikiLink} from "../_models/wiki"; import {AuthGuard} from "../_guards/auth.guard"; -import {SettingsService} from "../admin/settings.service"; /** * NavItem used to construct the dropdown or NavLinkModal on mobile @@ -57,6 +55,10 @@ export class NavService { transLocoKey: 'browse-tags', routerLink: '/browse/tags', }, + { + transLocoKey: 'all-annotations', + routerLink: '/browse/annotations' + }, { transLocoKey: 'announcements', routerLink: '/announcements/', 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 index 16b318bf3..b1b7f710f 100644 --- 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 @@ -3,7 +3,14 @@
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
- +
}
diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index 3e3522d5f..3b9de91f1 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -2,13 +2,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - DestroyRef, + DestroyRef, effect, EventEmitter, inject, Input, OnChanges, OnDestroy, - OnInit, Output } from '@angular/core'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; @@ -17,7 +16,6 @@ import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.ser import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; import {TranslocoDirective} from "@jsverse/transloco"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; import {User} from "../../_models/user"; @@ -33,7 +31,7 @@ import {User} from "../../_models/user"; styleUrls: ['./card-actionables.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy { +export class CardActionablesComponent implements OnChanges, OnDestroy { private readonly cdRef = inject(ChangeDetectorRef); private readonly accountService = inject(AccountService); @@ -65,17 +63,20 @@ export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy { submenu: {[key: string]: NgbDropdown} = {}; private closeTimeout: any = null; - - ngOnInit(): void { - this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + constructor() { + effect(() => { + const user = this.accountService.currentUserSignal(); if (!user) return; + this.currentUser = user; - this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!)); + this.actions = this.inputActions.filter(a => this.willRenderAction(a, user)); this.cdRef.markForCheck(); }); } ngOnChanges() { + if (!this.currentUser) return; // We can safely return as actionables will never be visible if there is no user + this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!)); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/admin/license/license.component.html b/UI/Web/src/app/admin/license/license.component.html index 81f7082cd..ce53155de 100644 --- a/UI/Web/src/app/admin/license/license.component.html +++ b/UI/Web/src/app/admin/license/license.component.html @@ -42,7 +42,7 @@ } } - @if (!isChecking() && hasLicense() && !licenseInfo) { + @if (!isChecking() && hasLicense() && !licenseInfo()) {
{{t('license-mismatch')}}
} @@ -203,6 +203,14 @@ {{t('manage')}} + +
+ + + +
} diff --git a/UI/Web/src/app/admin/license/license.component.ts b/UI/Web/src/app/admin/license/license.component.ts index 20773a11d..ef1431f08 100644 --- a/UI/Web/src/app/admin/license/license.component.ts +++ b/UI/Web/src/app/admin/license/license.component.ts @@ -56,7 +56,7 @@ export class LicenseComponent implements OnInit { if (!email) return environment.manageLink; return environment.manageLink + '?prefilled_email=' + encodeURIComponent(email); - }) + }); @@ -198,6 +198,17 @@ export class LicenseComponent implements OnInit { }); } + resendWelcomeEmail() { + this.licenseService.resendLicense().subscribe(res => { + if (res) { + this.toastr.success(translate('toasts.k+-resend-welcome-email-success')); + } else { + this.toastr.error(translate('toasts.k+-resend-welcome-message-error')); + } + + }) + } + updateEditMode(mode: boolean) { this.isViewMode.set(!mode); } diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts index 24531353d..20a428357 100644 --- a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts @@ -128,7 +128,7 @@ export class ManageMetadataMappingsComponent implements OnInit { export() { const data = this.packData(); - this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name')) + this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name')); } addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) { diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index 732b22a04..f3cd8fd39 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -3,6 +3,7 @@

{{t('title')}}

+
@if (settingsForm.get('taskScan'); as formControl) { diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index acfce84ec..9d4fedb87 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -18,7 +18,9 @@ import { } from 'rxjs'; import {ServerService} from 'src/app/_services/server.service'; import {Job} from 'src/app/_models/job/job'; -import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component'; +import { + UpdateNotificationModalComponent +} from 'src/app/announcements/_components/update-notification/update-notification-modal.component'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {DownloadService} from 'src/app/shared/_services/download.service'; import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; @@ -32,6 +34,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {DefaultModalOptions} from "../../_models/default-modal-options"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {AnnotationService} from "../../_services/annotation.service"; interface AdhocTask { name: string; @@ -59,6 +62,7 @@ export class ManageTasksSettingsComponent implements OnInit { private readonly serverService = inject(ServerService); private readonly modalService = inject(NgbModal); private readonly downloadService = inject(DownloadService); + private readonly annotationService = inject(AnnotationService); serverSettings!: ServerSettings; settingsForm: FormGroup = new FormGroup({}); @@ -331,6 +335,5 @@ export class ManageTasksSettingsComponent implements OnInit { }); } - - protected readonly ColumnMode = ColumnMode; + protected readonly ColumnMode = ColumnMode; } diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 745bdcbeb..bf3da7ed3 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -12,8 +12,8 @@ import {InviteUserComponent} from '../invite-user/invite-user.component'; import {EditUserComponent} from '../edit-user/edit-user.component'; import {Router} from '@angular/router'; import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component'; -import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common'; -import {size, TranslocoModule, TranslocoService} from "@jsverse/transloco"; +import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; +import {TranslocoModule, TranslocoService} from "@jsverse/transloco"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; @@ -124,7 +124,7 @@ export class ManageUsersComponent implements OnInit { setTimeout(() => { this.loadMembers(); this.toastr.success(this.translocoService.translate('toasts.user-deleted', {user: member.username})); - }, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush + }, 30); // SetTimeout because I've noticed this can run superfast and not give enough time for data to flush }); } } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 1340bc893..782ec3483 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -71,7 +71,7 @@ export class SettingsService { } isEmailSetup() { - return this.http.get(this.baseUrl + 'server/is-email-setup', TextResonse).pipe(map(d => d == "true")); + return this.http.get(this.baseUrl + 'settings/is-email-setup', TextResonse).pipe(map(d => d == "true")); } getTaskFrequencies() { diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.html b/UI/Web/src/app/all-annotations/all-annotations.component.html new file mode 100644 index 000000000..b2ffc4b20 --- /dev/null +++ b/UI/Web/src/app/all-annotations/all-annotations.component.html @@ -0,0 +1,52 @@ +
+ + +

+ {{t('title')}} +

+ +
{{t('annotations-count', {num: pagination().totalItems | number})}}
+ +
+ + + + + + + + + + + + + + + + + +
+
diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.scss b/UI/Web/src/app/all-annotations/all-annotations.component.scss new file mode 100644 index 000000000..cdeba597a --- /dev/null +++ b/UI/Web/src/app/all-annotations/all-annotations.component.scss @@ -0,0 +1,13 @@ + +::ng-deep #card-detail-layout-items-container { + + grid-template-columns: repeat(auto-fill, 25rem) !important; + justify-content: space-around; + + .card-detail-layout-item { + background-color: transparent !important; + max-width: 25rem; + width: 25rem; + } + +} diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.ts b/UI/Web/src/app/all-annotations/all-annotations.component.ts new file mode 100644 index 000000000..b06bda5e3 --- /dev/null +++ b/UI/Web/src/app/all-annotations/all-annotations.component.ts @@ -0,0 +1,197 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + EventEmitter, + inject, + OnInit, + signal +} from '@angular/core'; +import { + SideNavCompanionBarComponent +} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {ActivatedRoute, Router} from "@angular/router"; +import {AnnotationService} from "../_services/annotation.service"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; +import {Annotation} from "../book-reader/_models/annotations/annotation"; +import {Pagination} from "../_models/pagination"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {map, tap} from "rxjs/operators"; +import {AnnotationsFilterSettings} from "../metadata-filter/filter-settings"; +import { + AnnotationsFilter, + AnnotationsFilterField, + AnnotationsSortField +} from "../_models/metadata/v2/annotations-filter"; +import {MetadataService} from "../_services/metadata.service"; +import {FilterStatement} from "../_models/metadata/v2/filter-statement"; +import {FilterEvent} from "../_models/metadata/series-filter"; +import {DecimalPipe} from "@angular/common"; +import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; +import { + AnnotationCardComponent +} from "../book-reader/_components/_annotations/annotation-card/annotation-card.component"; +import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; +import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; +import {BulkSelectionService} from "../cards/bulk-selection.service"; +import {User} from "../_models/user"; + +@Component({ + selector: 'app-all-annotations', + imports: [ + SideNavCompanionBarComponent, + TranslocoDirective, + DecimalPipe, + CardDetailLayoutComponent, + AnnotationCardComponent, + BulkOperationsComponent + ], + templateUrl: './all-annotations.component.html', + styleUrl: './all-annotations.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AllAnnotationsComponent implements OnInit { + + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly annotationsService = inject(AnnotationService); + private readonly route = inject(ActivatedRoute); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly metadataService = inject(MetadataService); + private readonly actionFactoryService = inject(ActionFactoryService); + public readonly bulkSelectionService = inject(BulkSelectionService); + + isLoading = signal(true); + annotations = signal([]); + pagination = signal({ + currentPage: 0, + itemsPerPage: 0, + totalItems: 0, + totalPages: 0 + }); + filterActive = signal(false); + filter = signal(undefined); + + filterSettings: AnnotationsFilterSettings = new AnnotationsFilterSettings(); + trackByIdentity = (idx: number, item: Annotation) => `${item.id}`; + refresh: EventEmitter = new EventEmitter(); + filterOpen: EventEmitter = new EventEmitter(); + + actions: ActionItem[] = []; + + constructor() { + effect(() => { + const event = this.annotationsService.events(); + if (!event) return; + + switch (event.type) { + case "delete": + this.annotations.update(x => x.filter(a => a.id !== event.annotation.id)); + } + }); + + effect(() => { + this.annotations(); + this.bulkSelectionService.deselectAll(); + }); + } + + ngOnInit() { + this.actions = this.actionFactoryService.getAnnotationActions(this.actionFactoryService.dummyCallback); + + this.route.data.pipe( + takeUntilDestroyed(this.destroyRef), + map(data => data['filter'] as AnnotationsFilter | null | undefined), + tap(filter => { + if (!filter) { + filter = this.metadataService.createDefaultFilterDto('annotation'); + filter.statements.push(this.metadataService.createDefaultFilterStatement('annotation') as FilterStatement); + } + + this.filter.set(filter); + this.filterSettings.presetsV2 = this.filter(); + this.loadData(this.filter()) + }), + ).subscribe(); + } + + handleAction = async (action: ActionItem, entity: Annotation) => { + const selectedIndices = this.bulkSelectionService.getSelectedCardsForSource('annotations'); + const selectedAnnotations = this.annotations().filter((_, idx) => selectedIndices.includes(idx+'')); + const ids = selectedAnnotations.map(a => a.id); + + switch (action.action) { + case Action.Delete: + this.annotationsService.bulkDelete(ids).pipe( + tap(() => { + this.annotations.update(x => x.filter(a => !ids.includes(a.id))); + this.pagination.update(x => { + const count = this.annotations().length; + + return { + ...x, + totalItems: count, + totalPages: Math.ceil(count / x.itemsPerPage), + } + }) + }), + ).subscribe(); + break + case Action.Export: + this.annotationsService.exportAnnotations(ids).subscribe(); + break + } + } + + exportFilter() { + const filter = this.filter(); + if (!filter) return; + + this.annotationsService.exportFilter(filter).subscribe(); + } + + shouldRender = (action: ActionItem, entity: Annotation, user: User) => { + switch (action.action) { + case Action.Delete: + const selectedIndices = this.bulkSelectionService.getSelectedCardsForSource('annotations'); + const selectedAnnotations = this.annotations().filter((_, idx) => selectedIndices.includes(idx+'')); + return selectedAnnotations.find(a => a.ownerUsername !== user.username) === undefined; + } + + return true; + } + + private loadData(filter?: AnnotationsFilter) { + if (!filter) { + filter = this.metadataService.createDefaultFilterDto('annotation'); + filter.statements.push(this.metadataService.createDefaultFilterStatement('annotation') as FilterStatement); + } + + this.annotationsService.getAllAnnotationsFiltered(filter).pipe( + tap(a => { + this.annotations.set(a.result); + this.pagination.set(a.pagination); + }), + tap(() => this.isLoading.set(false)), + ).subscribe(); + } + + updateFilter(data: FilterEvent) { + if (!data.filterV2) { + return; + } + + if (!data.isFirst) { + this.filterUtilityService.updateUrlFromFilter(data.filterV2).pipe( + takeUntilDestroyed(this.destroyRef), + tap(() => this.filter.set(data.filterV2)), + tap(() => this.loadData(this.filter())) + ).subscribe(); + return; + } + + this.filter.set(data.filterV2); + } +} diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html index 570114b7b..ec5b59656 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html @@ -8,7 +8,6 @@
{{t('series-count', {num: pagination.totalItems | number})}}
} - @if (filter) { + + + + + {{t('view-in-reader-label')}} + [queryParams]="{annotation: annotation().id, incognitoMode: openInIncognitoMode()}">{{t('view-in-reader-label')}} } @@ -52,7 +52,8 @@ }
- @if(annotation().containsSpoiler) { + + @if(annotation().containsSpoiler) {
@@ -60,6 +61,10 @@
} + + @if (showSelectionBox()) { + + } 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 index 6ec77577a..c27d8a69a 100644 --- 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 @@ -52,16 +52,21 @@ export class AnnotationCardComponent { allowEdit = input(true); showPageLink = input(true); /** - * If sizes should be forced. Turned of in drawer to account for manual resize + * If sizes should be forced. Turned off in drawer to account for manual resize */ forceSize = input(true); /** * Redirects to the reader with annotation in view */ showInReaderLink = input(false); + showSelectionBox = input(false); + openInIncognitoMode = input(false); isInReader = input(true); + + selected = input(false); @Output() delete = new EventEmitter(); @Output() navigate = new EventEmitter(); + @Output() selection = new EventEmitter(); titleColor: Signal; hasClicked = model(false); 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 index 54c148858..090abb454 100644 --- 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 @@ -64,6 +64,7 @@ @if (an.ownerUsername === accountService.currentUserSignal()?.username) { + } } 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 index 5cc1e284a..39e563f62 100644 --- 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 @@ -11,11 +11,11 @@ import { ViewChild, ViewContainerRef } from '@angular/core'; -import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {NgbActiveModal, NgbActiveOffcanvas, NgbOffcanvas} 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 {translate, TranslocoDirective} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {debounceTime, switchMap} from "rxjs/operators"; import {of} from "rxjs"; @@ -32,7 +32,11 @@ import {QuillTheme, QuillWrapperComponent} from "../../quill-wrapper/quill-wrapp import {ContentChange, QuillViewComponent} from "ngx-quill"; import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe"; import {AccountService} from "../../../../_services/account.service"; -import {OffCanvasResizeComponent, ResizeMode} from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component"; +import { + OffCanvasResizeComponent, + ResizeMode +} from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component"; +import {ConfirmService} from "../../../../shared/confirm.service"; export enum AnnotationMode { View = 0, @@ -74,6 +78,8 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { private readonly fb = inject(NonNullableFormBuilder); protected readonly utilityService = inject(UtilityService); protected readonly accountService = inject(AccountService); + private readonly confirmService = inject(ConfirmService); + private readonly offcanvasService = inject(NgbOffcanvas); @ViewChild('renderTarget', {read: ViewContainerRef}) renderTarget!: ViewContainerRef; @@ -92,6 +98,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { selectedSlotIndex: FormControl, }>; annotationNote: object = {}; + annotationHtml: string = ''; constructor() { this.titleColor = computed(() => { @@ -214,6 +221,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { updatedAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value; updatedAnnotation.comment = JSON.stringify(this.annotationNote); + updatedAnnotation.commentHtml = this.annotationHtml; return this.annotationService.updateAnnotation(updatedAnnotation); }), @@ -238,6 +246,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { highlightAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value; highlightAnnotation.comment = JSON.stringify(this.annotationNote); + highlightAnnotation.commentHtml = this.annotationHtml; // For create annotation, we have to have this hack highlightAnnotation.createdUtc = '0001-01-01T00:00:00Z'; highlightAnnotation.lastModifiedUtc = '0001-01-01T00:00:00Z' @@ -273,8 +282,9 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { this.activeOffcanvas.close(); } - updateContent(event: ContentChange) { - this.annotationNote = event.content; + updateContent(event: {raw: ContentChange, html?: string}) { + this.annotationNote = event.raw.content; + this.annotationHtml = event.html ?? ''; } private initHighlights() { @@ -346,4 +356,15 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { protected readonly QuillTheme = QuillTheme; protected readonly ResizeMode = ResizeMode; protected readonly window = window; + + async delete() { + const annotation = this.annotation(); + if (!annotation) return; + + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'))) return; + + this.annotationService.deleteAnnotation(annotation).subscribe(_ => { + this.offcanvasService.dismiss(); + }); + } } 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 71e532abe..35351d641 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 @@ -69,7 +69,6 @@ import {environment} from "../../../../environments/environment"; import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component"; import {FontService} from "../../../_services/font.service"; import afterFrame from "afterframe"; -import {EpubPageCalculationMethod} from "../../../_models/readers/epub-page-calculation-method"; interface HistoryPoint { @@ -381,7 +380,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly readingDirection = this.readerSettingsService.readingDirection; protected readonly writingStyle = this.readerSettingsService.writingStyle; protected readonly clickToPaginate = this.readerSettingsService.clickToPaginate; - protected readonly pageCalcMode = this.readerSettingsService.pageCalcMode; protected columnWidth!: Signal; protected columnHeight!: Signal; @@ -537,22 +535,18 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const writingStyle = this.writingStyle(); const windowWidth = this.windowWidth(); + const marginLeft = this.pageStyles()['margin-left']; + const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth(); - // console.log('window width: ', windowWidth) - // console.log('book content width: ', this.readingSectionElemRef?.nativeElement?.clientWidth); - // console.log('column width: ', base / 4); - switch (layoutMode) { case BookPageLayoutMode.Default: return 'unset'; case BookPageLayoutMode.Column1: - return ((base / 2) - 4) + 'px'; + return Math.round(base / 2) + 'px'; case BookPageLayoutMode.Column2: - //return (this.readingSectionElemRef?.nativeElement?.clientWidth - this.getMargin() + 1) / 2 + 'px'; - return (((this.readingSectionElemRef?.nativeElement?.clientWidth ?? base)) / 4) + 1 + 'px' - //return ((base) / 4) + 6 + 'px' + return Math.round(base / 4) + 'px' default: return 'unset'; } @@ -581,7 +575,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (layoutMode !== BookPageLayoutMode.Default && writingStyle !== WritingStyle.Horizontal) { - console.log('verticalBookContentWidth: ', verticalPageWidth) return `${verticalPageWidth}px`; } return ''; @@ -977,7 +970,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (resumeElement !== null) { const element = this.getElementFromXPath(resumeElement); - console.log('Attempting to snap to element: ', element); + //console.log('Attempting to snap to element: ', element); this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect } @@ -1105,7 +1098,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { 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); window.history.replaceState({}, '', newRoute); @@ -1202,7 +1195,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadPage(part?: string | undefined, scrollTop?: number | undefined) { - console.log('load page called with: part: ', part, 'scrollTop: ', scrollTop); this.isLoading.set(true); this.cdRef.markForCheck(); @@ -1363,6 +1355,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { maxHeight = `${height}px`; } + maxWidth = `${(this.getVerticalPageWidth() / 2) - (COLUMN_GAP / 2)}px`; break } this.document.documentElement.style.setProperty('--book-reader-content-max-height', maxHeight); @@ -1439,25 +1432,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private scrollWithinPage(part?: string | undefined, scrollTop?: number) { if (part !== undefined && part !== '') { - - console.log('Scrolling via part: ', part); this.scroll(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part))); - - // afterFrame(() => { - // setTimeout(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part)), SCROLL_DELAY) - // }) - // - // setTimeout(() => { - // afterFrame(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part))); - // }, SCROLL_DELAY); return; } if (scrollTop !== undefined && scrollTop !== 0) { - // setTimeout(() => { - // afterFrame(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); - // }, SCROLL_DELAY); - console.log('Scrolling via scrollTop: ', scrollTop); this.scroll(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); return; } @@ -1467,56 +1446,36 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (layoutMode === BookPageLayoutMode.Default) { if (writingStyle === WritingStyle.Vertical) { - console.log('Scrolling via x axis: ', this.bookContentElemRef.nativeElement.clientWidth, ' via ', this.reader.nativeElement); + //console.log('Scrolling via x axis: ', this.bookContentElemRef.nativeElement.clientWidth, ' via ', this.reader.nativeElement); this.scroll(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); - // - // setTimeout(() => { - // afterFrame(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); - // }, SCROLL_DELAY); return; } - // setTimeout(() => { - // afterFrame(() => this.scrollService.scrollTo(0, this.reader.nativeElement)); - // }, SCROLL_DELAY); - console.log('Scrolling via x axis to 0: ', 0, ' via ', this.reader.nativeElement); + //console.log('Scrolling via x axis to 0: ', 0, ' via ', this.reader.nativeElement); this.scroll(() => this.scrollService.scrollTo(0, this.reader.nativeElement)); return; } if (writingStyle === WritingStyle.Vertical) { if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { - // setTimeout(() => { - // afterFrame(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto')); - // }, SCROLL_DELAY); - console.log('(Vertical) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollHeight, ' via ', this.bookContentElemRef.nativeElement); + //console.log('(Vertical) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollHeight, ' via ', this.bookContentElemRef.nativeElement); this.scroll(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto')); return; } - // setTimeout(() => { - // afterFrame(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement, 'auto')); - // }, SCROLL_DELAY); - console.log('(Vertical) Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement); + //console.log('(Vertical) Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement); this.scroll(() => 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(() => { - // afterFrame(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); - // }, SCROLL_DELAY); - console.log('(Page Back) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollWidth, ' via ', this.bookContentElemRef.nativeElement); + //console.log('(Page Back) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollWidth, ' via ', this.bookContentElemRef.nativeElement); this.scroll(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); return; } - // setTimeout(() => { - // afterFrame(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); - // }, SCROLL_DELAY); - - console.log('Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement); + //console.log('Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement); this.scroll(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); } @@ -1589,7 +1548,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage > 1) { // Calculate the target scroll position for the previous page - const targetScroll = (currentVirtualPage - 2) * pageSize + const targetScroll = (currentVirtualPage - 2) * pageSize - (this.layoutMode() === BookPageLayoutMode.Column2 ? 3 : 0) + const isVertical = this.writingStyle() === WritingStyle.Vertical; // -2 apparently goes back 1 virtual page... @@ -1641,7 +1601,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage < totalVirtualPages) { // Calculate the target scroll position for the next page - const targetScroll = currentVirtualPage * pageSize; + const targetScroll = (currentVirtualPage * pageSize) + (this.layoutMode() === BookPageLayoutMode.Column2 ? 1 : 0); const isVertical = this.writingStyle() === WritingStyle.Vertical; // +0 apparently goes forward 1 virtual page... @@ -1683,38 +1643,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ pageWidth = computed(() => { this.windowWidth(); // Ensure re-compute when windows size changes (element clientWidth isn't a signal) - this.pageCalcMode(); - console.log('page width recalulated') - const calculationMethod = this.pageCalcMode(); const marginLeft = this.pageStyles()['margin-left']; + const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); const columnGapModifier = this.columnGapModifier(); if (this.readingSectionElemRef == null) return 0; - const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); - - // console.log('page size calc, client width: ', this.readingSectionElemRef.nativeElement.clientWidth) - // console.log('page size calc, margin: ', margin) - // console.log('page size calc, col gap: ', ((COLUMN_GAP / 2) * columnGapModifier)); - // console.log("clientWidth", this.readingSectionElemRef.nativeElement.clientWidth, "window", window.innerWidth, "margin", margin, "left", marginLeft) - // console.log('clientWidth: ', this.readingSectionElemRef.nativeElement.clientWidth, 'offsetWidth:', this.readingSectionElemRef.nativeElement.offsetWidth, 'bbox:', this.readingSectionElemRef.nativeElement.getBoundingClientRect().width); - - if (calculationMethod === EpubPageCalculationMethod.Default) { - return this.readingSectionElemRef.nativeElement.clientWidth - margin + (((COLUMN_GAP) * columnGapModifier)); - } else { - return this.readingSectionElemRef.nativeElement.clientWidth - margin + (((COLUMN_GAP) * columnGapModifier) + 10); - } + // Give an additional pixels for buffer + return this.readingSectionElemRef.nativeElement.clientWidth - margin + + (COLUMN_GAP * columnGapModifier); }); columnGapModifier = computed(() => { - const calculationMethod = this.pageCalcMode(); switch(this.layoutMode()) { case BookPageLayoutMode.Default: return 0; case BookPageLayoutMode.Column1: - return 1; case BookPageLayoutMode.Column2: - return calculationMethod === EpubPageCalculationMethod.Default ? 1 : 1.25; + return 1; } }); @@ -1737,7 +1683,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } convertVwToPx(vwValue: number) { - const viewportWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth || 0, window.innerWidth || 0); + const viewportWidth = Math.max(this.readingSectionElemRef?.nativeElement?.clientWidth ?? 0, window.innerWidth || 0); return (vwValue * viewportWidth) / 100; } @@ -1797,7 +1743,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { let resumeElement: string | null = null; if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null; - const container = this.getViewportBoundingRect(); + //const container = this.getViewportBoundingRect(); 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')) @@ -2339,7 +2285,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ getViewportBoundingRect(): Container { const margin = this.getMargin(); - //const [currentVirtualPage, _, pageSize] = this.getVirtualPage(); const pageSize = this.pageWidth(); const visibleBoundingBox = this.bookContentElemRef.nativeElement.getBoundingClientRect(); @@ -2353,7 +2298,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const bottomBarHeight = this.document.querySelector('.bottom-bar')?.getBoundingClientRect().height ?? 38; const topBarHeight = this.document.querySelector('.fixed-top')?.getBoundingClientRect().height ?? 48; -// console.log('bottom: ', visibleBoundingBox.bottom) // TODO: Bottom isn't ideal in scroll mode const left = margin; const top = topBarHeight; @@ -2362,9 +2306,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const height = bottom - top; const right = left + width; - console.log('Visible Viewport', { - left, right, top, bottom, width, height - }); + // console.log('Visible Viewport', { + // left, right, top, bottom, width, height + // }); return { left, right, top, bottom, width, height @@ -2386,12 +2330,58 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { redRect.style.top = `${viewport.top}px`; redRect.style.width = `${viewport.width}px`; redRect.style.height = `${viewport.height}px`; - redRect.style.border = '5px solid red'; + redRect.style.border = '1px solid red'; redRect.style.pointerEvents = 'none'; redRect.style.zIndex = '1000'; + redRect.title = `Width: ${viewport.width}px`; // Inject into the document this.document.body.appendChild(redRect); + + + // Insert margin boxes as well + const marginLeft = this.pageStyles()['margin-left']; + const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); + + + // Insert a debug element to help visualize + this.document.querySelector('#debug-marginLeft')?.remove(); + + // Create and inject the red rectangle div + let greenRect = this.document.createElement('div'); + greenRect.id = 'debug-marginLeft'; + greenRect.style.position = 'absolute'; + greenRect.style.left = `${viewport.left - margin}px`; + greenRect.style.top = `${viewport.top}px`; + greenRect.style.width = `${margin}px`; + greenRect.style.height = `${viewport.height}px`; + greenRect.style.border = '1px solid green'; + greenRect.style.pointerEvents = 'none'; + greenRect.style.zIndex = '1000'; + greenRect.title = `Width: ${margin}px`; + + // Inject into the document + this.document.body.appendChild(greenRect); + + + this.document.querySelector('#debug-marginRight')?.remove(); + + // Create and inject the red rectangle div + greenRect = this.document.createElement('div'); + greenRect.id = 'debug-marginRight'; + greenRect.style.position = 'absolute'; + greenRect.style.left = `${viewport.left + viewport.width}px`; + greenRect.style.top = `${viewport.top}px`; + greenRect.style.width = `${margin}px`; + greenRect.style.height = `${viewport.height}px`; + greenRect.style.border = '1px solid green'; + greenRect.style.pointerEvents = 'none'; + greenRect.style.zIndex = '1000'; + greenRect.title = `Width: ${margin}px`; + + // Inject into the document + this.document.body.appendChild(greenRect); + } /** 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 index 847d4bc76..d2a2a3ab6 100644 --- 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 @@ -3,7 +3,9 @@ [format]="format()" [theme]="theme()" [formControlName]="controlName()" - (onContentChanged)="contentChanged.emit($event)"> + (onContentChanged)="onContentChange($event)" + (onEditorCreated)="editor.set($event)" + >
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 index 9b243a6fe..acf76f917 100644 --- 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 @@ -1,5 +1,6 @@ -import {ChangeDetectionStrategy, Component, computed, EventEmitter, input, OnInit, Output} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, EventEmitter, input, OnInit, Output, signal} from '@angular/core'; import {ContentChange, QuillEditorComponent, QuillFormat} from "ngx-quill"; +import type QuillType from 'quill' import {FormGroup, ReactiveFormsModule} from "@angular/forms"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective} from "@jsverse/transloco"; @@ -123,7 +124,7 @@ export class QuillWrapperComponent { /** * Deligation of the quill onContentChange event */ - @Output() contentChanged = new EventEmitter(); + @Output() contentChanged = new EventEmitter<{raw: ContentChange, html?: string}>(); /** * Items to show in the toolbar @@ -161,4 +162,14 @@ export class QuillWrapperComponent { .filter(group => group.length > 0); }); + editor = signal(undefined); + + onContentChange($event: ContentChange) { + const quill = this.editor(); + this.contentChanged.emit({ + raw: $event, + html: quill?.getSemanticHTML() ?? '', + }) + } + } 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 05cf93b5c..827fc5503 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 @@ -148,27 +148,6 @@ }
- -
- - {{t('page-calc-method-tooltip')}} - - - -
-
- -
-
- 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 d0f5f1ecf..0edd34bcb 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 @@ -23,8 +23,6 @@ import {TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; import {BookReadingProfileFormGroup, EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service"; import {EpubFont} from "../../../_models/preferences/epub-font"; -import {EpubPageCalcMethodPipe} from "../../../_pipes/epub-page-calc-method.pipe"; -import {allCalcMethods, EpubPageCalculationMethod} from "../../../_models/readers/epub-page-calculation-method"; /** * Used for book reader. Do not use for other components @@ -87,7 +85,7 @@ export const bookColorThemes = [ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, NgClass, NgStyle, - TitleCasePipe, TranslocoDirective, EpubPageCalcMethodPipe] + TitleCasePipe, TranslocoDirective] }) export class ReaderSettingsComponent implements OnInit { @@ -116,7 +114,6 @@ export class ReaderSettingsComponent implements OnInit { protected parentReadingProfile!: Signal; protected currentReadingProfile!: Signal; protected epubFonts!: Signal; - protected pageCalcMode!: Signal; async ngOnInit() { @@ -133,7 +130,6 @@ export class ReaderSettingsComponent implements OnInit { this.parentReadingProfile = this.readerSettingsService.parentReadingProfile; this.currentReadingProfile = this.readerSettingsService.currentReadingProfile; this.epubFonts = this.readerSettingsService.epubFonts; - this.pageCalcMode = this.readerSettingsService.pageCalcMode; this.themes = this.readerSettingsService.getThemes(); @@ -181,5 +177,4 @@ export class ReaderSettingsComponent implements OnInit { protected readonly WritingStyle = WritingStyle; protected readonly ReadingDirection = ReadingDirection; protected readonly BookPageLayoutMode = BookPageLayoutMode; - protected readonly calcMethods = allCalcMethods; } diff --git a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts index b0b3d07c7..2e8bcda87 100644 --- a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts +++ b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts @@ -5,6 +5,8 @@ export interface Annotation { endingXPath: string | null; selectedText: string | null; comment: string; + commentHtml: string; + commentPlainText: string; containsSpoiler: boolean; pageNumber: number; selectedSlotIndex: 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 index 9a499c6d4..cdf8e8b9f 100644 --- a/UI/Web/src/app/book-reader/_models/annotations/highlight-slot.ts +++ b/UI/Web/src/app/book-reader/_models/annotations/highlight-slot.ts @@ -1,6 +1,5 @@ export interface HighlightSlot { id: number; - title: string; color: RgbaColor; slotNumber: number; } diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html index 9d091a064..22d472027 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html @@ -6,7 +6,6 @@
{{t('series-count', {num: series.length | number})}}
- @if (filter) { + + + + + - Bulk Actions + {{t('title')}} diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts index b68309782..207554120 100644 --- a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts @@ -8,7 +8,12 @@ import { Input, OnInit } from '@angular/core'; -import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; +import { + Action, + ActionFactoryService, + ActionItem, + ActionShouldRenderFunc +} from 'src/app/_services/action-factory.service'; import {BulkSelectionService} from '../bulk-selection.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common"; @@ -31,9 +36,10 @@ import {KEY_CODES} from "../../shared/_services/utility.service"; styleUrls: ['./bulk-operations.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class BulkOperationsComponent implements OnInit { +export class BulkOperationsComponent implements OnInit { - @Input({required: true}) actionCallback!: (action: ActionItem, data: any) => void; + @Input({required: true}) actionCallback!: (action: ActionItem, data: any) => void; + @Input() shouldRenderFunc?: ActionShouldRenderFunc; /** * Modal mode means don't fix to the top */ @@ -48,7 +54,7 @@ export class BulkOperationsComponent implements OnInit { @Input() marginRight: number = 8; hasMarkAsRead: boolean = false; hasMarkAsUnread: boolean = false; - actions: Array> = []; + actions: Array> = []; private readonly destroyRef = inject(DestroyRef); private readonly cdRef = inject(ChangeDetectorRef); @@ -74,7 +80,8 @@ export class BulkOperationsComponent implements OnInit { ngOnInit(): void { this.bulkSelectionService.actions$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(actions => { // We need to do a recursive callback apply - this.actions = this.actionFactoryService.applyCallbackToList(actions, this.actionCallback.bind(this)); + const shouldRender = this.shouldRenderFunc ? this.shouldRenderFunc.bind(this) : this.actionFactoryService.dummyShouldRender; + this.actions = this.actionFactoryService.applyCallbackToList(actions, this.actionCallback.bind(this), shouldRender); this.hasMarkAsRead = this.actionFactoryService.hasAction(this.actions, Action.MarkAsRead); this.hasMarkAsUnread = this.actionFactoryService.hasAction(this.actions, Action.MarkAsUnread); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index 6fa5ed66b..6d1c1ecbe 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs'; import {filter} from 'rxjs/operators'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; -type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList'; +type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList' | 'annotations'; /** * Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops. @@ -170,6 +170,10 @@ export class BulkSelectionService { return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); } + if (Object.keys(this.selectedCards).filter(item => item === 'annotations').length > 0) { + return this.actionFactory.getAnnotationActions(callback); + } + // Chapter/Volume return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), [...allowedActions, Action.SendTo]); } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 3b2a87699..92e104d60 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -25,9 +25,15 @@ } @if (filterSettings) { - + + + + + } + +
@@ -36,10 +42,10 @@ } -
+
@for (item of scroll.viewPortItems; track trackByIdentity(i, item); let i = $index) { -
@@ -60,7 +66,7 @@
@for (item of scroll.viewPortItems; track trackByIdentity(i, item); let i = $index) { -
+
} diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 31a8c4d2b..3767cf96a 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -1,5 +1,29 @@ -import { DOCUMENT, NgClass, NgTemplateOutlet } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, ContentChild, DestroyRef, ElementRef, EventEmitter, HostListener, inject, input, Input, OnChanges, OnInit, Output, signal, Signal, SimpleChange, SimpleChanges, TemplateRef, TrackByFunction, ViewChild, WritableSignal } from '@angular/core'; +import {DOCUMENT, NgClass, NgTemplateOutlet} from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + ContentChild, + DestroyRef, + ElementRef, + EventEmitter, + HostListener, + inject, + input, + Input, + OnChanges, + OnInit, + Output, + signal, + Signal, + SimpleChanges, + TemplateRef, + TrackByFunction, + ViewChild, + WritableSignal +} from '@angular/core'; import {NavigationStart, Router} from '@angular/router'; import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; @@ -43,7 +67,7 @@ const ANIMATION_TIME_MS = 0; changeDetection: ChangeDetectionStrategy.OnPush, standalone: true }) -export class CardDetailLayoutComponent implements OnInit, OnChanges { +export class CardDetailLayoutComponent implements OnInit, OnChanges, AfterViewInit { private document = inject(DOCUMENT); @@ -98,6 +122,14 @@ export class CardDetailLayoutComponent; @ContentChild('noData') noDataTemplate: TemplateRef | null = null; @ViewChild('.jump-bar') jumpBar!: ElementRef; + /** + * Template that is rendered next to the save button + */ + @ContentChild('extraButtons') extraButtonsRef!: TemplateRef; + /** + * Template that is rendered above the grid, but always below the filter + */ + @ContentChild('topBar') topBar!: TemplateRef; @ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent; @@ -105,6 +137,7 @@ export class CardDetailLayoutComponent | undefined> = signal(undefined); @@ -147,26 +180,58 @@ export class CardDetailLayoutComponent event instanceof NavigationStart), takeUntilDestroyed(this.destroyRef), map(evt => evt as NavigationStart), - tap(_ => this.tryToSaveJumpKey()), + tap(_ => this.tryToSaveJumpKey({})), ).subscribe(); } + ngAfterViewInit(): void { + if (this.resumed) return; + + this.jumpBarKeysToRender = [...this.jumpBarKeys]; + this.resizeJumpBar(); + + if (this.jumpBarKeysToRender.length > 0) { + this.resumed = true; + + // Check if there is an exact scroll position to restore + const scrollOffset = this.jumpbarService.getResumePosition(this.router.url); + if (scrollOffset > 0) { + setTimeout(() => { + this.virtualScroller.scrollToPosition(scrollOffset, ANIMATION_TIME_MS); + }, 100) + } else { + const resumeKey = this.jumpbarService.getResumeKey(this.router.url); + if (resumeKey === '') return; + const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey); + if (keys.length < 1) return; + + setTimeout(() => this.scrollTo(keys[0]), 100); + } + } + } + ngOnChanges(changes: SimpleChanges): void { this.jumpBarKeysToRender = [...this.jumpBarKeys]; this.resizeJumpBar(); - const startIndex = this.jumpbarService.getResumePosition(this.router.url); - if (startIndex > 0) { - setTimeout(() => this.virtualScroller.scrollToIndex(startIndex, true, 0, ANIMATION_TIME_MS), 10); - return; - } + if (this.jumpBarKeysToRender.length > 0) { + this.resumed = true; - if (changes.hasOwnProperty('isLoading')) { - const loadingChange = changes['isLoading'] as SimpleChange; - if (loadingChange.previousValue === true && loadingChange.currentValue === false) { - setTimeout(() => this.virtualScroller.scrollToIndex(0, true, 0, ANIMATION_TIME_MS), 10); + // Check if there is an exact scroll position to restore + const scrollOffset = this.jumpbarService.getResumePosition(this.router.url); + if (scrollOffset > 0) { + setTimeout(() => { + this.virtualScroller.scrollToPosition(scrollOffset, ANIMATION_TIME_MS); + }, 100) + } else { + const resumeKey = this.jumpbarService.getResumeKey(this.router.url); + if (resumeKey === '') return; + const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey); + if (keys.length < 1) return; + + setTimeout(() => this.scrollTo(keys[0]), 100); } } } @@ -198,8 +263,17 @@ export class CardDetailLayoutComponent this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex), ANIMATION_TIME_MS + 100); } - tryToSaveJumpKey() { - this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex); + tryToSaveJumpKey(item: any) { + let name = ''; + if (item.hasOwnProperty('seriesName')) { + name = item.seriesName; + } else if (item.hasOwnProperty('name')) { + name = item.name; + } else if (item.hasOwnProperty('title')) { + name = item.title; + } + this.jumpbarService.saveResumeKey(this.router.url, name.charAt(0)); + this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.scrollStartPosition); } protected readonly Breakpoint = Breakpoint; diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index a751336d5..95e3c4a3d 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -4,7 +4,6 @@

{{t('title')}}

{{t('item-count', {num: collections.length | number})}}
- + + + + + } - - - - @if (filter) { + + + + + - @if (filter) { + + + + + + [resettable]="true" + [templates]="template" + > + + {{label}} + @if (color) { + + } + + } } } @@ -63,7 +72,9 @@
@let label = uiLabel(); @if (label !== null) { - {{t(label.unit)}} + @if (label.unit) { + {{t(label.unit)}} + } @if (label.tooltip) { } diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 4fcca8eac..8a43a99a8 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -15,11 +15,22 @@ import { } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; -import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs'; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + Observable, + of, + pipe, + startWith, + switchMap, + tap +} from 'rxjs'; import {MetadataService} from 'src/app/_services/metadata.service'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {FilterField} from 'src/app/_models/metadata/v2/filter-field'; -import {AsyncPipe} from "@angular/common"; +import {AsyncPipe, NgStyle} from "@angular/common"; import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Select2, Select2Option} from "ng-select2-component"; @@ -27,6 +38,8 @@ import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {ValidFilterEntity} from "../../filter-settings"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; +import {AnnotationsFilterField} from "../../../_models/metadata/v2/annotations-filter"; +import {RgbaColor} from "../../../book-reader/_models/annotations/highlight-slot"; interface FieldConfig { type: PredicateType; @@ -55,50 +68,19 @@ class FilterRowUi { } } -const unitLabels: Map = new Map([ - [FilterField.ReadingDate, new FilterRowUi('unit-reading-date')], - [FilterField.AverageRating, new FilterRowUi('unit-average-rating')], - [FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')], - [FilterField.UserRating, new FilterRowUi('unit-user-rating')], - [FilterField.ReadLast, new FilterRowUi('unit-read-last')], -]); - -// const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name]; -// const NumberFields = [ -// FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, -// FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast -// ]; -// const DropdownFields = [ -// FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, -// FilterField.Translators, FilterField.Characters, FilterField.Publisher, -// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, -// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, -// FilterField.Writers, FilterField.Genres, FilterField.Libraries, -// FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, -// FilterField.Imprint, FilterField.Team, FilterField.Location, PersonFilterField.Role -// ]; -// const BooleanFields = [FilterField.WantToRead]; -// const DateFields = [FilterField.ReadingDate]; -// -// const DropdownFieldsWithoutMustContains = [ -// FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus -// ]; -// const DropdownFieldsThatIncludeNumberComparisons = [ -// FilterField.AgeRating -// ]; -// const NumberFieldsThatIncludeDateComparisons = [ -// FilterField.ReleaseYear -// ]; -// -// const FieldsThatShouldIncludeIsEmpty = [ -// FilterField.Summary, FilterField.UserRating, FilterField.Genres, -// FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, -// FilterField.Translators, FilterField.Characters, FilterField.Publisher, -// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, -// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, -// FilterField.Writers, FilterField.Imprint, FilterField.Team, -// FilterField.Location, -// ]; +const unitLabels: Map> = new Map([ + ['series', new Map([ + [FilterField.ReadingDate as number, new FilterRowUi('unit-reading-date')], + [FilterField.AverageRating as number, new FilterRowUi('unit-average-rating')], + [FilterField.ReadProgress as number, new FilterRowUi('unit-reading-progress')], + [FilterField.UserRating as number, new FilterRowUi('unit-user-rating')], + [FilterField.ReadLast as number, new FilterRowUi('unit-read-last')], + [FilterField.FileSize as number, new FilterRowUi('unit-file-size', 'disclaimer-file-size')] + ])], + ['annotation', new Map([ + [AnnotationsFilterField.HighlightSlots as number, new FilterRowUi('', 'disclaimer-highlight-slots')], + ])], +]) const StringComparisons = [ FilterComparison.Equal, @@ -139,7 +121,8 @@ const BooleanComparisons = [ NgbTooltip, TranslocoDirective, NgbInputDatepicker, - Select2 + Select2, + NgStyle ], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -200,10 +183,7 @@ export class MetadataFilterRowComponent this.comparisonSignal() !== FilterComparison.IsEmpty); - this.uiLabel = computed(() => { - if (!unitLabels.has(this.inputSignal())) return null; - return unitLabels.get(this.inputSignal()) as FilterRowUi; - }); + this.uiLabel = computed(() => unitLabels.get(this.entityType())?.get(this.inputSignal()) ?? null); this.isMultiSelectDropdownAllowed = computed(() => { return this.comparisonSignal() === FilterComparison.Contains || this.comparisonSignal() === FilterComparison.NotContains || this.comparisonSignal() === FilterComparison.MustContains; @@ -213,7 +193,11 @@ export class MetadataFilterRowComponent this.handleFieldChange(val)); + this.formGroup.get('input')?.valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + tap((val: string) => this.handleFieldChange(val)), + ).subscribe(); this.populateFromPreset(); this.formGroup.get('filterValue')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); @@ -323,6 +307,7 @@ export class MetadataFilterRowComponent(this.entityType()); const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains(this.entityType()); const dropdownFieldsThatIncludeNumberComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeNumberComparisons(this.entityType()); + const customComparisons = this.filterUtilitiesService.getCustomComparisons(this.entityType(), inputVal); if (stringFields.includes(inputVal)) { let comps = [...StringComparisons]; @@ -331,19 +316,23 @@ export class MetadataFilterRowComponent 0) { + comps = customComparisons; + } + this.validComparisons$.next([...new Set(comps)]); this.predicateType$.next(PredicateType.Text); if (this.loaded) { this.formGroup.get('filterValue')?.patchValue(''); - this.formGroup.get('comparison')?.patchValue(StringComparisons[0]); + this.formGroup.get('comparison')?.patchValue(comps[0]); } this.cdRef.markForCheck(); return; } if (numberFields.includes(inputVal)) { - const comps = [...NumberComparisons]; + let comps = [...NumberComparisons]; if (numberFieldsThatIncludeDateComparisons.includes(inputVal)) { comps.push(...DateComparisons); @@ -352,12 +341,16 @@ export class MetadataFilterRowComponent 0) { + comps = customComparisons; + } + this.validComparisons$.next([...new Set(comps)]); this.predicateType$.next(PredicateType.Number); if (this.loaded) { this.formGroup.get('filterValue')?.patchValue(0); - this.formGroup.get('comparison')?.patchValue(NumberComparisons[0]); + this.formGroup.get('comparison')?.patchValue(comps[0]); } this.cdRef.markForCheck(); @@ -365,35 +358,43 @@ export class MetadataFilterRowComponent 0) { + comps = customComparisons; + } + this.validComparisons$.next([...new Set(comps)]); this.predicateType$.next(PredicateType.Date); if (this.loaded) { this.formGroup.get('filterValue')?.patchValue(false); - this.formGroup.get('comparison')?.patchValue(DateComparisons[0]); + this.formGroup.get('comparison')?.patchValue(comps[0]); } this.cdRef.markForCheck(); return; } if (booleanFields.includes(inputVal)) { - let comps = [...DateComparisons]; + let comps = [...BooleanComparisons]; if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } + if (customComparisons && customComparisons.length > 0) { + comps = customComparisons; + } + this.validComparisons$.next([...new Set(comps)]); this.predicateType$.next(PredicateType.Boolean); if (this.loaded) { this.formGroup.get('filterValue')?.patchValue(false); - this.formGroup.get('comparison')?.patchValue(BooleanComparisons[0]); + this.formGroup.get('comparison')?.patchValue(comps[0]); } this.cdRef.markForCheck(); return; @@ -411,6 +412,10 @@ export class MetadataFilterRowComponent 0) { + comps = customComparisons; + } + this.validComparisons$.next([...new Set(comps)]); this.predicateType$.next(PredicateType.Dropdown); if (this.loaded) { @@ -430,6 +435,12 @@ export class MetadataFilterRowComponent { presetsV2: FilterV2 | undefined; @@ -36,4 +37,8 @@ export class PersonFilterSettings extends FilterSettingsBase { + type : ValidFilterEntity = 'annotation'; +} + diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index cbfb4dad9..c5f47a454 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -79,10 +79,13 @@
- +
+ + +
diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index d1fc264ef..c2b94321e 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -12,7 +12,7 @@ import { Input, OnInit, Output, - Signal + Signal, TemplateRef } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; @@ -77,6 +77,11 @@ export class MetadataFilterComponent> = new EventEmitter(); @ContentChild('[ngbCollapse]') collapse!: NgbCollapse; + /** + * Template that is rendered next to the save button + */ + @ContentChild('extraButtons') extraButtonsRef!: TemplateRef; + /** diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index 9d8ec4a64..521b880c3 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -10,7 +10,6 @@ } - + + + + + (event: HttpEvent): event is HttpResponse { +import {HttpEvent, HttpEventType, HttpHeaders, HttpProgressEvent, HttpResponse} from "@angular/common/http"; +import {Observable} from "rxjs"; +import {scan} from "rxjs/operators"; + +function isHttpResponse(event: HttpEvent): event is HttpResponse { return event.type === HttpEventType.Response; } - + function isHttpProgressEvent( event: HttpEvent ): event is HttpProgressEvent { @@ -16,8 +16,8 @@ import { HttpEvent, HttpEventType, HttpHeaders, HttpProgressEvent, HttpResponse } /** - * Encapsulates an inprogress download of a Blob with progress reporting activated - */ + * Encapsulates an in progress download of a Blob with progress reporting activated + */ export interface Download { content: Blob | null; progress: number; @@ -26,7 +26,7 @@ export interface Download { loaded?: number; total?: number } - + export function download(saver?: (b: Blob, filename: string) => void): (source: Observable>) => Observable { return (source: Observable>) => source.pipe( @@ -63,7 +63,7 @@ export function download(saver?: (b: Blob, filename: string) => void): (source: function getFilename(headers: HttpHeaders, defaultName: string) { const tokens = (headers.get('content-disposition') || '').split(';'); - let filename = tokens[1].replace('filename=', '').replace(/"/ig, '').trim(); + let filename = tokens[1].replace('filename=', '').replace(/"/ig, '').trim(); if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) { const ext = filename.substring(filename.lastIndexOf('.'), filename.length); if (defaultName !== '') { @@ -73,4 +73,4 @@ function getFilename(headers: HttpHeaders, defaultName: string) { //return defaultName + ext; } return filename; - } \ No newline at end of file + } diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index 471979d56..b736c3ab8 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -1,4 +1,4 @@ -import {inject, Injectable} from '@angular/core'; +import {inject, Injectable, PipeTransform} from '@angular/core'; import {Params, Router} from '@angular/router'; import {allSeriesSortFields, SortField} from 'src/app/_models/metadata/series-filter'; import {MetadataService} from "../../_services/metadata.service"; @@ -22,7 +22,16 @@ import { import {SortFieldPipe} from "../../_pipes/sort-field.pipe"; import {GenericFilterFieldPipe} from "../../_pipes/generic-filter-field.pipe"; import {TranslocoService} from "@jsverse/transloco"; +import { + allAnnotationsFilterFields, + allAnnotationsSortFields, + AnnotationsFilterField +} from "../../_models/metadata/v2/annotations-filter"; +export interface FieldOption { + title: string, + value: T, +} @Injectable({ providedIn: 'root' @@ -114,16 +123,12 @@ export class FilterUtilitiesService { */ getSortFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return this.translateAndSort(type, this.sortFieldPipe, allAnnotationsSortFields) as FieldOption[]; case 'series': - return allSeriesSortFields.map(f => { - return {title: this.sortFieldPipe.transform(f, type), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + return this.translateAndSort(type, this.sortFieldPipe, allSeriesSortFields) as FieldOption[]; case 'person': - return allPersonSortFields.map(f => { - return {title: this.sortFieldPipe.transform(f, type), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; - default: - return [] as {title: string, value: T}[]; + return this.translateAndSort(type, this.sortFieldPipe, allPersonSortFields) as FieldOption[]; } } @@ -131,27 +136,35 @@ export class FilterUtilitiesService { * Returns the Filter Fields for the Metadata filter based on the entity. * @param type */ - getFilterFields(type: ValidFilterEntity): {title: string, value: T}[] { + getFilterFields(type: ValidFilterEntity): FieldOption[] { switch (type) { + case "annotation": + return this.translateAndSort(type, this.genericFilterFieldPipe, allAnnotationsFilterFields) as FieldOption[]; case 'series': - return allSeriesFilterFields.map(f => { - return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + return this.translateAndSort(type, this.genericFilterFieldPipe, allSeriesFilterFields) as FieldOption[]; case 'person': - return allPersonFilterFields.map(f => { - return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; - default: - return [] as {title: string, value: T}[]; + return this.translateAndSort(type, this.genericFilterFieldPipe, allPersonFilterFields) as FieldOption[]; } } + private translateAndSort(type: ValidFilterEntity, pipe: PipeTransform, items: T[]): FieldOption[] { + return items + .map(item => { + return {title: pipe.transform(item, type), value: item}; + }) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); + } + /** * Returns the default field for the Series or Person entity aka what should be there if there are no statements * @param type */ getDefaultFilterField(type: ValidFilterEntity) { switch (type) { + case "annotation": + return AnnotationsFilterField.Owner as unknown as T; case 'series': return FilterField.SeriesName as unknown as T; case 'person': @@ -165,6 +178,11 @@ export class FilterUtilitiesService { */ getDropdownFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + AnnotationsFilterField.Owner, AnnotationsFilterField.Library, + AnnotationsFilterField.HighlightSlots, + ] as T[]; case 'series': return [ FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, @@ -188,9 +206,13 @@ export class FilterUtilitiesService { */ getStringFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + AnnotationsFilterField.Comment, AnnotationsFilterField.Selection, + ] as T[]; case 'series': return [ - FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath + FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, FilterField.FileSize, ] as unknown as T[]; case 'person': return [ @@ -201,6 +223,10 @@ export class FilterUtilitiesService { getNumberFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, @@ -215,6 +241,10 @@ export class FilterUtilitiesService { getBooleanFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + AnnotationsFilterField.Spoiler, + ] as T[]; case 'series': return [ FilterField.WantToRead @@ -228,6 +258,10 @@ export class FilterUtilitiesService { getDateFields(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.ReadingDate @@ -241,6 +275,10 @@ export class FilterUtilitiesService { getNumberFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.ReleaseYear @@ -254,6 +292,10 @@ export class FilterUtilitiesService { getDropdownFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.AgeRating @@ -267,6 +309,10 @@ export class FilterUtilitiesService { getDropdownFieldsWithoutMustContains(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus @@ -280,6 +326,10 @@ export class FilterUtilitiesService { getDropdownFieldsThatIncludeNumberComparisons(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.AgeRating @@ -293,6 +343,10 @@ export class FilterUtilitiesService { getFieldsThatShouldIncludeIsEmpty(type: ValidFilterEntity) { switch (type) { + case "annotation": + return [ + + ] as T[]; case 'series': return [ FilterField.Summary, FilterField.UserRating, FilterField.Genres, @@ -319,4 +373,22 @@ export class FilterUtilitiesService { return new SeriesFilterSettings(); } + + /** + * Fully override which comparisons a field offers. This MUST return at least one FilterComparison + */ + getCustomComparisons(entityType: ValidFilterEntity, field: T): FilterComparison[] | null { + switch (entityType) { + case "series": + switch (field) { + case FilterField.FileSize: + return [ + FilterComparison.Equal, FilterComparison.GreaterThan, FilterComparison.GreaterThanEqual, + FilterComparison.LessThan, FilterComparison.LessThanEqual + ] + } + } + + return null; + } } diff --git a/UI/Web/src/app/shared/drawer/drawer.component.ts b/UI/Web/src/app/shared/drawer/drawer.component.ts index 745bc1f68..22bd53c9d 100644 --- a/UI/Web/src/app/shared/drawer/drawer.component.ts +++ b/UI/Web/src/app/shared/drawer/drawer.component.ts @@ -1,5 +1,13 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, inject } from '@angular/core'; -import {CommonModule} from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + Output +} from '@angular/core'; +import {NgStyle} from "@angular/common"; import {TranslocoDirective} from "@jsverse/transloco"; export class DrawerOptions { @@ -11,7 +19,7 @@ export class DrawerOptions { @Component({ selector: 'app-drawer', - imports: [CommonModule, TranslocoDirective], + imports: [TranslocoDirective, NgStyle], templateUrl: './drawer.component.html', styleUrls: ['./drawer.component.scss'], exportAs: "drawer", diff --git a/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts b/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts index 61a4cbf3f..866d017d3 100644 --- a/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts +++ b/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import {CommonModule} from "@angular/common"; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {NgClass} from "@angular/common"; /** * What type of cursor to apply to the tag badge @@ -24,7 +24,9 @@ export enum TagBadgeCursor { @Component({ selector: 'app-tag-badge', - imports: [CommonModule], + imports: [ + NgClass + ], templateUrl: './tag-badge.component.html', styleUrls: ['./tag-badge.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html index b76c46116..914912930 100644 --- a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html +++ b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html @@ -1,6 +1,8 @@ - + + + @if (items.length > 3) {
diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html index 3b008e774..102f66941 100644 --- a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html @@ -49,13 +49,11 @@ {{t('load-filter')}} } @case (SideNavStreamType.ExternalSource) { - {{item.externalSource!.host!}} - -} + {{item.externalSource!.host!}} + } } -
-} + }
diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts index 66da113b8..6e02bc146 100644 --- a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output} from '@angular/core'; -import { APP_BASE_HREF, CommonModule } from '@angular/common'; +import {APP_BASE_HREF, NgClass} from '@angular/common'; import {SideNavStream} from "../../../_models/sidenav/sidenav-stream"; import {StreamNamePipe} from "../../../_pipes/stream-name.pipe"; import {TranslocoDirective} from "@jsverse/transloco"; @@ -7,7 +7,7 @@ import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.en @Component({ selector: 'app-sidenav-stream-list-item', - imports: [CommonModule, StreamNamePipe, TranslocoDirective], + imports: [StreamNamePipe, TranslocoDirective, NgClass], templateUrl: './sidenav-stream-list-item.component.html', styleUrls: ['./sidenav-stream-list-item.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush 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 be29d0952..b6932d6f9 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 @@ -38,7 +38,7 @@ import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {UploadService} from 'src/app/_services/upload.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {CommonModule} from "@angular/common"; +import {DatePipe, NgTemplateOutlet} from "@angular/common"; import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; import {translate, TranslocoModule} from "@jsverse/transloco"; @@ -72,9 +72,9 @@ enum StepID { @Component({ selector: 'app-library-settings-modal', - imports: [CommonModule, NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip, + imports: [NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip, SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe, - FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe], + FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe], templateUrl: './library-settings-modal.component.html', styleUrls: ['./library-settings-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html index d02f1ef83..ede4d2135 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html @@ -8,6 +8,12 @@

{{t('description')}}

+ @if (!hasEmailSetup) { + + } +
-
- - - {{readingProfileForm.get('bookReaderEpubPageCalculationMethod')!.value | epubPageCalcMethod}} - - - - - -
-
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 8eda89975..949ba7137 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 @@ -64,8 +64,6 @@ import {ColorscapeService} from "../../_services/colorscape.service"; import {Color} from "@iplab/ngx-color-picker"; import {FontService} from "../../_services/font.service"; import {EpubFont} from "../../_models/preferences/epub-font"; -import {EpubPageCalcMethodPipe} from "../../_pipes/epub-page-calc-method.pipe"; -import {allCalcMethods} from "../../_models/readers/epub-page-calculation-method"; enum TabId { ImageReader = "image-reader", @@ -104,8 +102,7 @@ enum TabId { LoadingComponent, NgbTooltip, BreakpointPipe, - SettingColorPickerComponent, - EpubPageCalcMethodPipe, + SettingColorPickerComponent ], templateUrl: './manage-reading-profiles.component.html', styleUrl: './manage-reading-profiles.component.scss', @@ -232,7 +229,6 @@ export class ManageReadingProfilesComponent implements OnInit { this.readingProfileForm.addControl('bookReaderLayoutMode', new FormControl(this.selectedProfile.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); this.readingProfileForm.addControl('bookReaderThemeName', new FormControl(this.selectedProfile.bookReaderThemeName || bookColorThemes[0].name, [])); this.readingProfileForm.addControl('bookReaderImmersiveMode', new FormControl(this.selectedProfile.bookReaderImmersiveMode, [])); - this.readingProfileForm.addControl('bookReaderEpubPageCalculationMethod', new FormControl(this.selectedProfile.bookReaderEpubPageCalculationMethod, [])); // Pdf reader this.readingProfileForm.addControl('pdfTheme', new FormControl(this.selectedProfile.pdfTheme || PdfTheme.Dark, [])); @@ -342,7 +338,6 @@ export class ManageReadingProfilesComponent implements OnInit { } protected readonly readingDirections = readingDirections; - protected readonly calcMethods = allCalcMethods; protected readonly pdfSpreadModes = pdfSpreadModes; protected readonly pageSplitOptions = pageSplitOptions; protected readonly bookLayoutModes = bookLayoutModes; 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 f42fc348a..90e10425f 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 @@ -65,7 +65,9 @@ export class ManageUserPreferencesComponent implements OnInit { constructor() { this.localizationService.getLocales().subscribe(res => { - this.locales = res; + this.locales = res.sort((l1, l2) => { + return l1.renderName.localeCompare(l2.renderName) + }); this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html index 86e18ad0c..ae55b049c 100644 --- a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html +++ b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html @@ -12,8 +12,6 @@
- - @if (filter) { + + + + +