diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs index 7806ef660..bee612fa2 100644 --- a/API/Controllers/BaseApiController.cs +++ b/API/Controllers/BaseApiController.cs @@ -10,4 +10,8 @@ namespace API.Controllers; [Authorize] public class BaseApiController : ControllerBase { + public BaseApiController() + { + + } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index e1d7da9e8..75061889d 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Reader; using API.Entities.Enums; @@ -40,11 +41,13 @@ public class BookController : BaseApiController /// /// [HttpGet("{chapterId}/book-info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])] public async Task> GetBookInfo(int chapterId) { var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var bookTitle = string.Empty; + switch (dto.SeriesFormat) { case MangaFormat.Epub: @@ -52,6 +55,7 @@ public class BookController : BaseApiController var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); bookTitle = book.Title; + break; } case MangaFormat.Pdf: @@ -72,9 +76,9 @@ public class BookController : BaseApiController break; } - return Ok(new BookInfoDto() + var info = new BookInfoDto() { - ChapterNumber = dto.ChapterNumber, + ChapterNumber = dto.ChapterNumber, VolumeNumber = dto.VolumeNumber, VolumeId = dto.VolumeId, BookTitle = bookTitle, @@ -84,7 +88,13 @@ public class BookController : BaseApiController LibraryId = dto.LibraryId, IsSpecial = dto.IsSpecial, Pages = dto.Pages, - }); + }; + + + + + + return Ok(info); } /// @@ -157,7 +167,11 @@ public class BookController : BaseApiController try { - return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); + var ptocBookmarks = + await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page); + var annotations = await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapter.Id); + + return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations)); } catch (KavitaException ex) { diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 38a5ad482..41b8420aa 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -15,6 +15,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using API.Services.Tasks.Metadata; using API.SignalR; using Hangfire; using Kavita.Common; @@ -41,6 +42,7 @@ public class ReaderController : BaseApiController private readonly IEventHub _eventHub; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IBookService _bookService; /// public ReaderController(ICacheService cacheService, @@ -48,7 +50,8 @@ public class ReaderController : BaseApiController IReaderService readerService, IBookmarkService bookmarkService, IAccountService accountService, IEventHub eventHub, IScrobblingService scrobblingService, - ILocalizationService localizationService) + ILocalizationService localizationService, + IBookService bookService) { _cacheService = cacheService; _unitOfWork = unitOfWork; @@ -59,6 +62,7 @@ public class ReaderController : BaseApiController _eventHub = eventHub; _scrobblingService = scrobblingService; _localizationService = localizationService; + _bookService = bookService; } /// @@ -218,11 +222,10 @@ public class ReaderController : BaseApiController /// This is generally the first call when attempting to read to allow pre-generation of assets needed for reading /// /// Should Kavita extract pdf into images. Defaults to false. - /// Include file dimensions. Only useful for image based reading + /// Include file dimensions. Only useful for image-based reading /// [HttpGet("chapter-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions" - ])] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])] public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) { if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore @@ -826,6 +829,58 @@ public class ReaderController : BaseApiController return _readerService.GetTimeEstimate(0, pagesLeft, false); } + + /// + /// For the current user, returns an estimate on how long it would take to finish reading the chapter. + /// + /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases. + /// + /// + /// + [HttpGet("time-left-for-chapter")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "chapterId"])] + public async Task> GetEstimateToCompletionForChapter(int seriesId, int chapterId) + { + var userId = User.GetUserId(); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + + // Patch in the reading progress + await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter); + + if (series.Format == MangaFormat.Epub) + { + // Get the word counts for all the pages + var pageCounts = await _bookService.GetWordCountsPerPage(chapter.Files.First().FilePath); // TODO: Cache + if (pageCounts == null) return _readerService.GetTimeEstimate(series.WordCount, 0, true); + + // Sum character counts only for pages that have been read + var totalCharactersRead = pageCounts + .Where(kvp => kvp.Key <= chapter.PagesRead) + .Sum(kvp => kvp.Value); + + var progressCount = WordCountAnalyzerService.GetWordCount(totalCharactersRead); + var wordsLeft = series.WordCount - progressCount; + return _readerService.GetTimeEstimate(wordsLeft, 0, true); + } + + var pagesLeft = chapter.Pages - chapter.PagesRead; + return _readerService.GetTimeEstimate(0, pagesLeft, false); + } + + /// + /// Returns the annotations for the given chapter + /// + /// + /// + [HttpGet("annotations")] + public async Task>> GetAnnotations(int chapterId) + { + + return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId)); + } + /// /// Returns the user's personal table of contents for the given chapter /// @@ -879,6 +934,12 @@ public class ReaderController : BaseApiController return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark")); } + // Look up the chapter this PTOC is associated with to get the chapter title (if there is one) + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + var toc = await _bookService.GenerateTableOfContents(chapter); + var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber); + _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() { Title = dto.Title.Trim(), @@ -887,6 +948,8 @@ public class ReaderController : BaseApiController SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, BookScrollId = dto.BookScrollId, + SelectedText = dto.SelectedText, + ChapterTitle = chapterTitle, AppUserId = userId }); await _unitOfWork.CommitAsync(); diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs new file mode 100644 index 000000000..fe0be79e1 --- /dev/null +++ b/API/DTOs/Reader/AnnotationDto.cs @@ -0,0 +1,46 @@ +using System; +using API.Entities; +using API.Entities.Enums; + +namespace API.DTOs.Reader; + +/// +/// Represents an annotation on a book +/// +public sealed record AnnotationDto +{ + public int Id { get; set; } + /// + /// Starting point of the Highlight + /// + public required string XPath { get; set; } + /// + /// Ending point of the Highlight. Can be the same as + /// + public string EndingXPath { get; set; } + + /// + /// The text selected. + /// + public string SelectedText { get; set; } + /// + /// Rich text Comment + /// + public string? Comment { get; set; } + /// + /// The number of characters selected + /// + public int HighlightCount { get; set; } + public bool ContainsSpoiler { get; set; } + public int PageNumber { get; set; } + + public HightlightColor HighlightColor { get; set; } + + public required int ChapterId { get; set; } + + public required int OwnerUserId { get; set; } + public string OwnerUsername { get; set; } + + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/DTOs/Reader/CreateAnnotationRequest.cs b/API/DTOs/Reader/CreateAnnotationRequest.cs new file mode 100644 index 000000000..676f90ed7 --- /dev/null +++ b/API/DTOs/Reader/CreateAnnotationRequest.cs @@ -0,0 +1,35 @@ +using API.Entities.Enums; + +namespace API.DTOs.Reader; + +public sealed record CreateAnnotationRequest +{ + public int Id { get; set; } + /// + /// Starting point of the Highlight + /// + public required string XPath { get; set; } + /// + /// Ending point of the Highlight. Can be the same as + /// + public string EndingXPath { get; set; } + + /// + /// The text selected. + /// + public string SelectedText { get; set; } + /// + /// Rich text Comment + /// + public string? Comment { get; set; } + /// + /// The number of characters selected + /// + public int HighlightCount { get; set; } + public bool ContainsSpoiler { get; set; } + public int PageNumber { get; set; } + + public HightlightColor HighlightColor { get; set; } + + public required int ChapterId { get; set; } +} diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 95272ca58..545e17e47 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto public required int PageNumber { get; set; } public required string Title { get; set; } public string? BookScrollId { get; set; } + public string? SelectedText { get; set; } } diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index c979d9d78..66994a7ff 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -4,8 +4,27 @@ public sealed record PersonalToCDto { + public required int Id { get; init; } public required int ChapterId { get; set; } + /// + /// The page to bookmark + /// public required int PageNumber { get; set; } + /// + /// The title of the bookmark. Defaults to Page {PageNumber} if not set + /// public required string Title { get; set; } + /// + /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page + /// public string? BookScrollId { get; set; } + /// + /// Text of the bookmark + /// + public string? SelectedText { get; set; } + /// + /// Title of the Chapter this PToC was created in + /// + /// Taken from the ToC + public string? ChapterTitle { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7d529b1da..98c7d6980 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext MetadataFieldMapping { get; set; } = null!; public DbSet AppUserChapterRating { get; set; } = null!; public DbSet AppUserReadingProfiles { get; set; } = null!; + public DbSet AppUserAnnotation { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs b/API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs new file mode 100644 index 000000000..f3c10a534 --- /dev/null +++ b/API/Data/Migrations/20250708204811_BookAnnotations.Designer.cs @@ -0,0 +1,3814 @@ +// +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("20250708204811_BookAnnotations")] + partial class BookAnnotations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + 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("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("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("Comment") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightColor") + .HasColumnType("INTEGER"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("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("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + 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("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + 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/20250708204811_BookAnnotations.cs b/API/Data/Migrations/20250708204811_BookAnnotations.cs new file mode 100644 index 000000000..81fe51954 --- /dev/null +++ b/API/Data/Migrations/20250708204811_BookAnnotations.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class BookAnnotations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChapterTitle", + table: "AppUserTableOfContent", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SelectedText", + table: "AppUserTableOfContent", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "AppUserAnnotation", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + XPath = table.Column(type: "TEXT", nullable: true), + EndingXPath = table.Column(type: "TEXT", nullable: true), + SelectedText = table.Column(type: "TEXT", nullable: true), + Comment = table.Column(type: "TEXT", nullable: true), + HighlightCount = table.Column(type: "INTEGER", nullable: false), + PageNumber = table.Column(type: "INTEGER", nullable: false), + HighlightColor = table.Column(type: "INTEGER", nullable: false), + ContainsSpoiler = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserAnnotation", x => x.Id); + table.ForeignKey( + name: "FK_AppUserAnnotation_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserAnnotation_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserAnnotation_AppUserId", + table: "AppUserAnnotation", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserAnnotation_ChapterId", + table: "AppUserAnnotation", + column: "ChapterId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserAnnotation"); + + migrationBuilder.DropColumn( + name: "ChapterTitle", + table: "AppUserTableOfContent"); + + migrationBuilder.DropColumn( + name: "SelectedText", + table: "AppUserTableOfContent"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 62d1fb1ef..d5de68777 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -154,6 +154,69 @@ namespace API.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightColor") + .HasColumnType("INTEGER"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.ToTable("AppUserAnnotation"); + }); + modelBuilder.Entity("API.Entities.AppUserBookmark", b => { b.Property("Id") @@ -826,6 +889,9 @@ namespace API.Data.Migrations b.Property("ChapterId") .HasColumnType("INTEGER"); + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + b.Property("Created") .HasColumnType("TEXT"); @@ -844,6 +910,9 @@ namespace API.Data.Migrations b.Property("PageNumber") .HasColumnType("INTEGER"); + b.Property("SelectedText") + .HasColumnType("TEXT"); + b.Property("SeriesId") .HasColumnType("INTEGER"); @@ -2823,6 +2892,25 @@ namespace API.Data.Migrations b.ToTable("SeriesMetadataTag"); }); + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + }); + modelBuilder.Entity("API.Entities.AppUserBookmark", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -3609,6 +3697,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.AppUser", b => { + b.Navigation("Annotations"); + b.Navigation("Bookmarks"); b.Navigation("ChapterRatings"); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 6437cfcfe..bae2f1d09 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -107,6 +107,7 @@ public interface IUserRepository Task> GetDashboardStreamsByIds(IList streamIds); Task> GetUserTokenInfo(); Task GetUserByDeviceEmail(string deviceEmail); + Task> GetAnnotations(int userId, int chapterId); } public class UserRepository : IUserRepository @@ -550,13 +551,28 @@ public class UserRepository : IUserRepository /// /// /// - public async Task GetUserByDeviceEmail(string deviceEmail) + public async Task GetUserByDeviceEmail(string deviceEmail) { return await _context.AppUser .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) .FirstOrDefaultAsync(); } + /// + /// Returns a list of annotations ordered by page number. If the user has + /// + /// + /// + /// + public async Task> GetAnnotations(int userId, int chapterId) + { + // TODO: Check settings if I should include other user's annotations + return await _context.AppUserAnnotation + .Where(a => a.AppUserId == userId && a.ChapterId == chapterId) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task> GetAdminUsersAsync() { diff --git a/API/Data/Repositories/UserTableOfContentRepository.cs b/API/Data/Repositories/UserTableOfContentRepository.cs index b640ec9a0..34b3994de 100644 --- a/API/Data/Repositories/UserTableOfContentRepository.cs +++ b/API/Data/Repositories/UserTableOfContentRepository.cs @@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository void Remove(AppUserTableOfContent toc); Task IsUnique(int userId, int chapterId, int page, string title); IEnumerable GetPersonalToC(int userId, int chapterId); + Task> GetPersonalToCForPage(int userId, int chapterId, int page); Task Get(int userId, int chapterId, int pageNum, string title); } @@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository .AsEnumerable(); } + public async Task> GetPersonalToCForPage(int userId, int chapterId, int page) + { + return await _context.AppUserTableOfContent + .Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.PageNumber) + .ToListAsync(); + } + public async Task Get(int userId,int chapterId, int pageNum, string title) { return await _context.AppUserTableOfContent diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 848636209..186ac8df0 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -46,6 +46,7 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// A list of Table of Contents for a given Chapter /// public ICollection TableOfContents { get; set; } = null!; + public ICollection Annotations { get; set; } = null!; /// /// An API Key to interact with external services, like OPDS /// diff --git a/API/Entities/AppUserAnnotation.cs b/API/Entities/AppUserAnnotation.cs new file mode 100644 index 000000000..dbda265d6 --- /dev/null +++ b/API/Entities/AppUserAnnotation.cs @@ -0,0 +1,53 @@ +using System; +using API.Entities.Enums; +using API.Entities.Interfaces; + +namespace API.Entities; + +/// +/// Represents an annotation in the Epub reader +/// +public class AppUserAnnotation : IEntityDate +{ + public int Id { get; set; } + /// + /// Starting point of the Highlight + /// + public required string XPath { get; set; } + /// + /// Ending point of the Highlight. Can be the same as + /// + public string EndingXPath { get; set; } + + /// + /// The text selected. + /// + public string SelectedText { get; set; } + /// + /// Rich text Comment + /// + public string? Comment { get; set; } + /// + /// The number of characters selected + /// + public int HighlightCount { get; set; } + public int PageNumber { get; set; } + + public HightlightColor HighlightColor { get; set; } + public bool ContainsSpoiler { get; set; } + + // TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?) + + public required int SeriesId { get; set; } + public required int VolumeId { get; set; } + public required int ChapterId { get; set; } + public Chapter Chapter { get; set; } + + public required int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Entities/AppUserTableOfContent.cs b/API/Entities/AppUserTableOfContent.cs index bc0f604bc..5d110b8b6 100644 --- a/API/Entities/AppUserTableOfContent.cs +++ b/API/Entities/AppUserTableOfContent.cs @@ -18,6 +18,19 @@ public class AppUserTableOfContent : IEntityDate /// The title of the bookmark. Defaults to Page {PageNumber} if not set /// public required string Title { get; set; } + /// + /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page + /// + public string? BookScrollId { get; set; } + /// + /// Text of the bookmark + /// + public string? SelectedText { get; set; } + /// + /// Title of the Chapter this PToC was created in + /// + /// Taken from the ToC + public string? ChapterTitle { get; set; } public required int SeriesId { get; set; } public virtual Series Series { get; set; } @@ -27,10 +40,7 @@ public class AppUserTableOfContent : IEntityDate public int VolumeId { get; set; } public int LibraryId { get; set; } - /// - /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page - /// - public string? BookScrollId { get; set; } + public DateTime Created { get; set; } public DateTime CreatedUtc { get; set; } diff --git a/API/Entities/Enums/HightlightColor.cs b/API/Entities/Enums/HightlightColor.cs new file mode 100644 index 000000000..dc60b46e8 --- /dev/null +++ b/API/Entities/Enums/HightlightColor.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Enums; + +/// +/// Color of the highlight +/// +/// Color may not match exactly due to theming +public enum HightlightColor +{ + Blue = 1, + Green = 2, +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index bb7511c64..dbe346efa 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -386,7 +386,9 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); - + CreateMap() + .ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName)) + .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)); } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 99fdd1400..cfb026e0a 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -14,6 +14,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; using API.Helpers; +using API.Services.Tasks.Metadata; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -57,11 +58,12 @@ public interface IBookService /// Where the files will be extracted to. If doesn't exist, will be created. void ExtractPdfImages(string fileFilePath, string targetDirectory); Task> GenerateTableOfContents(Chapter chapter); - Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); + Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List ptocBookmarks, List annotations); Task> CreateKeyToPageMappingAsync(EpubBookRef book); + Task?> GetWordCountsPerPage(string bookFilePath); } -public class BookService : IBookService +public partial class BookService : IBookService { private readonly ILogger _logger; private readonly IDirectoryService _directoryService; @@ -321,6 +323,71 @@ public class BookService : IBookService } } + /// + /// For each bookmark on this page, inject a specialized icon + /// + /// + /// + /// + private static void InjectPTOCBookmarks(HtmlDocument doc, EpubBookRef book, List ptocBookmarks) + { + if (ptocBookmarks.Count == 0) return; + + foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId))) + { + var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant(); + var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector); + elem?.PrependChild(HtmlNode.CreateNode($"")); + } + } + + + private static void InjectAnnotations(HtmlDocument doc, EpubBookRef book, List annotations) + { + if (annotations.Count == 0) return; + + foreach (var annotation in annotations.Where(b => !string.IsNullOrEmpty(b.XPath))) + { + var unscopedSelector = annotation.XPath.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant(); + var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector); + if (elem == null) continue; + + // For this POC, assume we have 16 characters highlighted and those characters are: "For the past few" + // Get the original text content + var originalText = elem.InnerText; + + // For POC: highlight first 16 characters + var highlightLength = annotation.HighlightCount; + + if (originalText.Length > highlightLength) + { + var highlightedText = originalText.Substring(0, highlightLength); + var remainingText = originalText.Substring(highlightLength); + + // Clear the existing content + elem.RemoveAllChildren(); + + // Create the highlight element with the first 16 characters + var highlightNode = HtmlNode.CreateNode($"{highlightedText}"); + elem.AppendChild(highlightNode); + + // Add the remaining text as a text node + var remainingTextNode = HtmlNode.CreateNode(remainingText); + elem.AppendChild(remainingTextNode); + } + else + { + // If text is shorter than highlight length, wrap it all + var highlightNode = HtmlNode.CreateNode($"{originalText}"); + elem.RemoveAllChildren(); + elem.AppendChild(highlightNode); + } + } + } + + + + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) { var images = doc.DocumentNode.SelectNodes("//img") @@ -365,6 +432,23 @@ public class BookService : IBookService } + private static void InjectImages(HtmlDocument doc, EpubBookRef book, string apiBase) + { + var images = doc.DocumentNode.SelectNodes("//img") + ?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg"); + + if (images == null) return; + + var parent = images[0].ParentNode; + + foreach (var image in images) + { + // TODO: How do I make images clickable with state? + //image.AddClass("kavita-scale-width"); + } + + } + /// /// Returns the image key associated with the file. Contains some basic fallback logic. /// @@ -873,6 +957,50 @@ public class BookService : IBookService return dict; } + public async Task?> GetWordCountsPerPage(string bookFilePath) + { + var ret = new Dictionary(); + try + { + using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions); + var mappings = await CreateKeyToPageMappingAsync(book); + + var doc = new HtmlDocument {OptionFixNestedTags = true}; + + + var bookPages = await book.GetReadingOrderAsync(); + foreach (var contentFileRef in bookPages) + { + var page = mappings[contentFileRef.Key]; + var content = await contentFileRef.ReadContentAsync(); + doc.LoadHtml(content); + + var body = doc.DocumentNode.SelectSingleNode("//body"); + + if (body == null) + { + _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); + doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); + body = doc.DocumentNode.SelectSingleNode("//html/body"); + } + + // Find all words in the html body + // TEMP: REfactor this to use WordCountAnalyzerService + var textNodes = body!.SelectNodes("//text()[not(parent::script)]"); + ret.Add(page, textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0); + + } + + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue calculating word counts per page"); + return null; + } + + return ret; + } + /// /// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books) /// then null is returned. This expects only an epub file @@ -1016,8 +1144,10 @@ public class BookService : IBookService /// Body element from the epub /// Epub mappings /// Page number we are loading + /// Ptoc Bookmarks to tie against /// - private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + private async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, + Dictionary mappings, int page, List ptocBookmarks, List annotations) { await InlineStyles(doc, book, apiBase, body); @@ -1025,6 +1155,13 @@ public class BookService : IBookService ScopeImages(doc, book, apiBase); + InjectImages(doc, book, apiBase); + + // Inject PTOC Bookmark Icons + InjectPTOCBookmarks(doc, book, ptocBookmarks); + + InjectAnnotations(doc, book, annotations); + return PrepareFinalHtml(doc, body); } @@ -1089,6 +1226,88 @@ public class BookService : IBookService /// public async Task> GenerateTableOfContents(Chapter chapter) { + // using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); + // var mappings = await CreateKeyToPageMappingAsync(book); + // + // var navItems = await book.GetNavigationAsync(); + // var chaptersList = new List(); + // + // if (navItems != null) + // { + // foreach (var navigationItem in navItems) + // { + // if (navigationItem.NestedItems.Count == 0) + // { + // CreateToCChapter(book, navigationItem, Array.Empty(), chaptersList, mappings); + // continue; + // } + // + // var nestedChapters = new List(); + // + // foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null)) + // { + // var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath); + // if (mappings.TryGetValue(key, out var mapping)) + // { + // nestedChapters.Add(new BookChapterItem + // { + // Title = nestedChapter.Title, + // Page = mapping, + // Part = nestedChapter.Link?.Anchor ?? string.Empty, + // Children = [] + // }); + // } + // } + // + // CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings); + // } + // } + // + // if (chaptersList.Count != 0) return chaptersList; + // // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist) + // var tocPage = book.Content.Html.Local.Select(s => s.Key) + // .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) || + // k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase)); + // if (string.IsNullOrEmpty(tocPage)) return chaptersList; + // + // + // // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content + // if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList; + // var content = await file.ReadContentAsync(); + // + // var doc = new HtmlDocument(); + // doc.LoadHtml(content); + // + // // TODO: We may want to check if there is a toc.ncs file to better handle nested toc + // // We could do a fallback first with ol/lis + // + // + // + // var anchors = doc.DocumentNode.SelectNodes("//a"); + // if (anchors == null) return chaptersList; + // + // foreach (var anchor in anchors) + // { + // if (!anchor.Attributes.Contains("href")) continue; + // + // var key = CoalesceKey(book, mappings, anchor.Attributes["href"].Value.Split("#")[0]); + // + // if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue; + // var part = string.Empty; + // if (anchor.Attributes["href"].Value.Contains('#')) + // { + // part = anchor.Attributes["href"].Value.Split("#")[1]; + // } + // chaptersList.Add(new BookChapterItem + // { + // Title = anchor.InnerText, + // Page = mappings[key], + // Part = part, + // Children = [] + // }); + // } + // + // return chaptersList; using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); @@ -1099,53 +1318,29 @@ public class BookService : IBookService { foreach (var navigationItem in navItems) { - if (navigationItem.NestedItems.Count == 0) + var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings); + if (tocItem != null) { - CreateToCChapter(book, navigationItem, Array.Empty(), chaptersList, mappings); - continue; + chaptersList.Add(tocItem); } - - var nestedChapters = new List(); - - foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null)) - { - var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath); - if (mappings.TryGetValue(key, out var mapping)) - { - nestedChapters.Add(new BookChapterItem - { - Title = nestedChapter.Title, - Page = mapping, - Part = nestedChapter.Link?.Anchor ?? string.Empty, - Children = new List() - }); - } - } - - CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings); } } if (chaptersList.Count != 0) return chaptersList; + + // Rest of your fallback logic remains the same... // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist) var tocPage = book.Content.Html.Local.Select(s => s.Key) .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) || k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase)); if (string.IsNullOrEmpty(tocPage)) return chaptersList; - - // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList; var content = await file.ReadContentAsync(); var doc = new HtmlDocument(); doc.LoadHtml(content); - // TODO: We may want to check if there is a toc.ncs file to better handle nested toc - // We could do a fallback first with ol/lis - - - var anchors = doc.DocumentNode.SelectNodes("//a"); if (anchors == null) return chaptersList; @@ -1166,19 +1361,55 @@ public class BookService : IBookService Title = anchor.InnerText, Page = mappings[key], Part = part, - Children = new List() + Children = [] }); } return chaptersList; } + private BookChapterItem? CreateToCChapterRecursively(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary mappings) + { + // Get the page mapping for the current navigation item + var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath); + int? page = null; + if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping)) + { + page = mapping; + } + + // Recursively process nested items + var children = new List(); + if (navigationItem.NestedItems?.Count > 0) + { + foreach (var nestedItem in navigationItem.NestedItems) + { + var childItem = CreateToCChapterRecursively(book, nestedItem, mappings); + if (childItem != null) + { + children.Add(childItem); + } + } + } + + // Only create a BookChapterItem if we have a valid page or children + if (page.HasValue || children.Count > 0) + { + return new BookChapterItem + { + Title = navigationItem.Title ?? string.Empty, + Page = page ?? 0, // You might want to handle this differently + Part = navigationItem.Link?.Anchor ?? string.Empty, + Children = children + }; + } + + return null; + } + private static int CountParentDirectory(string path) { - const string pattern = @"\.\./"; - var matches = Regex.Matches(path, pattern); - - return matches.Count; + return ParentDirectoryRegex().Matches(path).Count; } /// @@ -1215,7 +1446,8 @@ public class BookService : IBookService /// The API base for Kavita, to rewrite urls to so we load though our endpoint /// Full epub HTML Page, scoped to Kavita's reader /// All exceptions throw this - public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) + public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, + List ptocBookmarks, List annotations) { using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); @@ -1257,7 +1489,7 @@ public class BookService : IBookService body = doc.DocumentNode.SelectSingleNode("/html/body"); } - return await ScopePage(doc, book, apiBase, body, mappings, page); + return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations); } } catch (Exception ex) { @@ -1343,6 +1575,28 @@ public class BookService : IBookService return string.Empty; } + public static string? GetChapterTitleFromToC(ICollection? tableOfContents, int pageNumber) + { + if (tableOfContents == null) return null; + + foreach (var item in tableOfContents) + { + // Check if current item matches the page number + if (item.Page == pageNumber) + return item.Title; + + // Recursively search children if they exist + if (item.Children?.Count > 0) + { + var childResult = GetChapterTitleFromToC(item.Children, pageNumber); + if (childResult != null) + return childResult; + } + } + + return null; + } + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { @@ -1432,4 +1686,7 @@ public class BookService : IBookService _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); } } + + [GeneratedRegex(@"\.\./")] + private static partial Regex ParentDirectoryRegex(); } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 35cfa7b04..2ab135dfc 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -423,11 +423,7 @@ public class EmailService : IEmailService smtpClient.Timeout = 20000; var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None; - await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl); - if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password)) - { - await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password); - } + ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; @@ -445,6 +441,12 @@ public class EmailService : IEmailService try { + await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl); + if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password)) + { + await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password); + } + await smtpClient.SendAsync(email); if (user != null) { diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index bff7001bd..4eaf3d278 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -35,7 +35,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService private readonly IReaderService _readerService; private readonly IMediaErrorService _mediaErrorService; - private const int AverageCharactersPerWord = 5; + public const int AverageCharactersPerWord = 5; public WordCountAnalyzerService(ILogger logger, IUnitOfWork unitOfWork, IEventHub eventHub, ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService) @@ -247,7 +247,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService _unitOfWork.MangaFileRepository.Update(file); } - private async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath) { try @@ -256,7 +255,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService doc.LoadHtml(await bookFile.ReadContentAsync()); var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); - return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; + var characterCount = textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0; + return GetWordCount(characterCount); } catch (EpubContentException ex) { @@ -267,4 +267,10 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } } + public static int GetWordCount(int characterCount) + { + if (characterCount == 0) return 0; + return characterCount / AverageCharactersPerWord; + } + } diff --git a/UI/Web/src/app/_models/common/i-has-reading-time.ts b/UI/Web/src/app/_models/common/i-has-reading-time.ts index 41753d1fd..3c6a25dd3 100644 --- a/UI/Web/src/app/_models/common/i-has-reading-time.ts +++ b/UI/Web/src/app/_models/common/i-has-reading-time.ts @@ -4,5 +4,4 @@ export interface IHasReadingTime { avgHoursToRead: number; pages: number; wordCount: number; - } diff --git a/UI/Web/src/app/_models/readers/personal-toc.ts b/UI/Web/src/app/_models/readers/personal-toc.ts index 3d4c3c9af..b0ce7aa20 100644 --- a/UI/Web/src/app/_models/readers/personal-toc.ts +++ b/UI/Web/src/app/_models/readers/personal-toc.ts @@ -3,6 +3,9 @@ export interface PersonalToC { pageNumber: number; title: string; bookScrollId: string | undefined; + selectedText: string | null; + chapterTitle: string | null; /* Ui Only */ position: 0; + } diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts index 43ac41c86..5dd04dc75 100644 --- a/UI/Web/src/app/_pipes/read-time-left.pipe.ts +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -1,7 +1,6 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {TranslocoService} from "@jsverse/transloco"; import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; -import {DecimalPipe} from "@angular/common"; @Pipe({ name: 'readTimeLeft', @@ -11,10 +10,10 @@ export class ReadTimeLeftPipe implements PipeTransform { constructor(private readonly translocoService: TranslocoService) {} - transform(readingTimeLeft: HourEstimateRange): string { + transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string { const hoursLabel = readingTimeLeft.avgHours > 1 - ? this.translocoService.translate('read-time-pipe.hours') - : this.translocoService.translate('read-time-pipe.hour'); + ? this.translocoService.translate(`read-time-pipe.hours${includeLeftLabel ? '-left' : ''}`) + : this.translocoService.translate(`read-time-pipe.hour${includeLeftLabel ? '-left' : ''}`); const formattedHours = this.customRound(readingTimeLeft.avgHours); diff --git a/UI/Web/src/app/_service/annotation-card.service.ts b/UI/Web/src/app/_service/annotation-card.service.ts new file mode 100644 index 000000000..a45ec1555 --- /dev/null +++ b/UI/Web/src/app/_service/annotation-card.service.ts @@ -0,0 +1,66 @@ +import {ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, inject, Injectable} from '@angular/core'; +import { + AnnotationCardComponent +} from '../book-reader/_components/_annotations/annotation-card/annotation-card.component'; + +@Injectable({ + providedIn: 'root' +}) +export class AnnotationCardService { + + private readonly applicationRef = inject(ApplicationRef); + + private componentRef?: ComponentRef; + + show(config: { + position: any; + annotationText?: string; + createdDate?: Date; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + }): ComponentRef { + // Remove existing card if present + this.hide(); + + // Create component using createComponent (Angular 13+ approach) + this.componentRef = createComponent(AnnotationCardComponent, { + environmentInjector: this.applicationRef.injector + }); + + // Set inputs using signals + this.componentRef.setInput('position', config.position); + this.componentRef.setInput('annotationText', config.annotationText || 'This is test text'); + this.componentRef.setInput('createdDate', config.createdDate || new Date()); + + // Set up event handlers + if (config.onMouseEnter) { + this.componentRef.instance.mouseEnter.subscribe(config.onMouseEnter); + } + if (config.onMouseLeave) { + this.componentRef.instance.mouseLeave.subscribe(config.onMouseLeave); + } + + // Attach to application + this.applicationRef.attachView(this.componentRef.hostView); + + // Append to body + const domElem = (this.componentRef.hostView as EmbeddedViewRef).rootNodes[0] as HTMLElement; + document.body.appendChild(domElem); + + return this.componentRef; + } + + hide(): void { + if (this.componentRef) { + this.applicationRef.detachView(this.componentRef.hostView); + this.componentRef.destroy(); + this.componentRef = undefined; + } + } + + updateHoverState(isHovered: boolean): void { + if (this.componentRef) { + this.componentRef.instance.isHovered.set(isHovered); + } + } +} diff --git a/UI/Web/src/app/_services/epub-reader-menu.service.ts b/UI/Web/src/app/_services/epub-reader-menu.service.ts new file mode 100644 index 000000000..d8938a143 --- /dev/null +++ b/UI/Web/src/app/_services/epub-reader-menu.service.ts @@ -0,0 +1,122 @@ +import {inject, Injectable, signal} from '@angular/core'; +import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request"; +import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import { + ViewAnnotationDrawerComponent +} from "../book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component"; +import { + CreateAnnotationDrawerComponent +} from "../book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component"; +import { + ViewBookmarkDrawerComponent +} from "../book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component"; +import { + LoadPageEvent, + ViewTocDrawerComponent +} from "../book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component"; +import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; +import { + EpubSettingDrawerComponent, +} from "../book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component"; +import {ReadingProfile} from "../_models/preferences/reading-profiles"; + +/** + * Responsible for opening the different readers and providing any context needed. Handles closing or keeping a stack of menus open. + */ +@Injectable({ + providedIn: 'root' +}) +export class EpubReaderMenuService { + + private readonly offcanvasService = inject(NgbOffcanvas); + private readonly utilityService = inject(UtilityService); + + /** + * The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded + */ + public readonly isDrawerOpen = signal(false); + + openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) { + const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''}); + ref.closed.subscribe(() => this.setDrawerClosed()); + ref.dismissed.subscribe(() => this.setDrawerClosed()); + ref.componentInstance.createAnnotation.set(annotation); + + this.isDrawerOpen.set(true); + } + + + openViewAnnotationsDrawer(chapterId: number) { + if (this.offcanvasService.hasOpenOffcanvas()) { + this.offcanvasService.dismiss(); + } + const ref = this.offcanvasService.open(ViewAnnotationDrawerComponent, {position: 'end', panelClass: ''}); + ref.closed.subscribe(() => this.setDrawerClosed()); + ref.dismissed.subscribe(() => this.setDrawerClosed()); + + this.isDrawerOpen.set(true); + } + + openViewTocDrawer(chapterId: number, callbackFn: (evt: LoadPageEvent | null) => void) { + if (this.offcanvasService.hasOpenOffcanvas()) { + this.offcanvasService.dismiss(); + } + const ref = this.offcanvasService.open(ViewTocDrawerComponent, {position: 'end', panelClass: ''}); + ref.componentInstance.chapterId.set(chapterId); + ref.componentInstance.loadPage.subscribe((res: LoadPageEvent | null) => { + // Check if we are on mobile to collapse the menu + if (this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Mobile) { + this.closeAll(); + } + callbackFn(res); + }); + ref.closed.subscribe(() => this.setDrawerClosed()); + ref.dismissed.subscribe(() => this.setDrawerClosed()); + + this.isDrawerOpen.set(true); + } + + openViewBookmarksDrawer(chapterId: number) { + if (this.offcanvasService.hasOpenOffcanvas()) { + this.offcanvasService.dismiss(); + } + const ref = this.offcanvasService.open(ViewBookmarkDrawerComponent, {position: 'end', panelClass: ''}); + ref.componentInstance.chapterId.set(chapterId); + ref.closed.subscribe(() => this.setDrawerClosed()); + ref.dismissed.subscribe(() => this.setDrawerClosed()); + + this.isDrawerOpen.set(true); + + } + + + openSettingsDrawer(chapterId: number, seriesId: number, readingProfile: ReadingProfile) { + if (this.offcanvasService.hasOpenOffcanvas()) { + this.offcanvasService.dismiss(); + } + const ref = this.offcanvasService.open(EpubSettingDrawerComponent, {position: 'start', panelClass: ''}); + ref.componentInstance.chapterId.set(chapterId); + ref.componentInstance.seriesId.set(seriesId); + ref.componentInstance.readingProfile.set(readingProfile); + + ref.closed.subscribe(() => this.setDrawerClosed()); + ref.dismissed.subscribe(() => this.setDrawerClosed()); + + this.isDrawerOpen.set(true); + } + + closeAll() { + if (this.offcanvasService.hasOpenOffcanvas()) { + this.offcanvasService.dismiss(); + } + this.setDrawerClosed(); + } + + setDrawerClosed() { + console.log('Drawer closed'); + this.isDrawerOpen.set(false); + } + + + +} diff --git a/UI/Web/src/app/_services/epub-reader-settings.service.ts b/UI/Web/src/app/_services/epub-reader-settings.service.ts new file mode 100644 index 000000000..97e5441d2 --- /dev/null +++ b/UI/Web/src/app/_services/epub-reader-settings.service.ts @@ -0,0 +1,693 @@ +import {computed, DestroyRef, effect, inject, Injectable, signal} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; +import {bookColorThemes, PageStyle} from "../book-reader/_components/reader-settings/reader-settings.component"; +import {ReadingDirection} from '../_models/preferences/reading-direction'; +import {WritingStyle} from '../_models/preferences/writing-style'; +import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; +import {FormControl, FormGroup} from "@angular/forms"; +import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles"; +import {BookService, FontFamily} from "../book-reader/_services/book.service"; +import {ThemeService} from './theme.service'; +import {ReadingProfileService} from "./reading-profile.service"; +import {debounceTime, skip, tap} from "rxjs/operators"; +import {BookTheme} from "../_models/preferences/book-theme"; +import {DOCUMENT} from "@angular/common"; +import {translate} from "@jsverse/transloco"; +import {ToastrService} from "ngx-toastr"; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; + +export interface ReaderSettingUpdate { + setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme'; + object: any; +} + + +@Injectable({ + providedIn: 'root' +}) +export class EpubReaderSettingsService { + private readonly destroyRef = inject(DestroyRef); + private readonly bookService = inject(BookService); + private readonly themeService = inject(ThemeService); + private readonly readingProfileService = inject(ReadingProfileService); + private readonly toastr = inject(ToastrService); + private readonly document = inject(DOCUMENT); + + // Core signals - these will be the single source of truth + private readonly _currentReadingProfile = signal(null); + private readonly _parentReadingProfile = signal(null); + private readonly _currentSeriesId = signal(null); + private readonly _isInitialized = signal(false); + + // Settings signals + private readonly _pageStyles = signal(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements + private readonly _readingDirection = signal(ReadingDirection.LeftToRight); + private readonly _writingStyle = signal(WritingStyle.Horizontal); + private readonly _activeTheme = signal(undefined); + private readonly _clickToPaginate = signal(false); + private readonly _layoutMode = signal(BookPageLayoutMode.Default); + private readonly _immersiveMode = signal(false); + private readonly _isFullscreen = signal(false); + + // Form will be managed separately but updated from signals + private settingsForm: FormGroup = new FormGroup({}); + private fontFamilies: FontFamily[] = this.bookService.getFontFamilies(); + private isUpdatingFromForm = false; // Flag to prevent infinite loops + + // Event subject for component communication (keep this for now, can be converted to effect later) + private settingUpdateSubject = new Subject(); + + // Public readonly signals + public readonly currentReadingProfile = this._currentReadingProfile.asReadonly(); + public readonly parentReadingProfile = this._parentReadingProfile.asReadonly(); + public readonly isInitialized = this._isInitialized.asReadonly(); + + // Settings as readonly signals + public readonly pageStyles = this._pageStyles.asReadonly(); + public readonly readingDirection = this._readingDirection.asReadonly(); + public readonly writingStyle = this._writingStyle.asReadonly(); + public readonly activeTheme = this._activeTheme.asReadonly(); + public readonly clickToPaginate = this._clickToPaginate.asReadonly(); + public readonly layoutMode = this._layoutMode.asReadonly(); + public readonly immersiveMode = this._immersiveMode.asReadonly(); + public readonly isFullscreen = this._isFullscreen.asReadonly(); + + // Computed signals for derived state + public readonly canPromoteProfile = computed(() => { + const profile = this._currentReadingProfile(); + return profile !== null && profile.kind === ReadingProfileKind.Implicit; + }); + + public readonly hasParentProfile = computed(() => { + return this._parentReadingProfile() !== null; + }); + + // Keep observable for now - can be converted to effect later + public readonly settingUpdates$ = this.settingUpdateSubject.asObservable(); + + constructor() { + // Effect to update form when signals change (only when not updating from form) + effect(() => { + const profile = this._currentReadingProfile(); + if (profile && this._isInitialized() && !this.isUpdatingFromForm) { + this.updateFormFromSignals(); + } + }); + + // Effect to emit setting updates when signals change + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'pageStyle', + object: this._pageStyles() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'clickToPaginate', + object: this._clickToPaginate() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'layoutMode', + object: this._layoutMode() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'readingDirection', + object: this._readingDirection() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'writingStyle', + object: this._writingStyle() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + this.settingUpdateSubject.next({ + setting: 'immersiveMode', + object: this._immersiveMode() + }); + }); + + effect(() => { + if (!this._isInitialized()) return; + + const theme = this._activeTheme(); + if (theme) { + this.settingUpdateSubject.next({ + setting: 'theme', + object: theme + }); + } + }); + } + + + /** + * Initialize the service with a reading profile and series ID + */ + async initialize(seriesId: number, readingProfile: ReadingProfile): Promise { + this._currentSeriesId.set(seriesId); + this._currentReadingProfile.set(readingProfile); + + console.log('init, reading profile: ', readingProfile); + + // Load parent profile if needed + if (readingProfile.kind === ReadingProfileKind.Implicit) { + try { + const parent = await this.readingProfileService.getForSeries(seriesId, true).toPromise(); + this._parentReadingProfile.set(parent || null); + } catch (error) { + console.error('Failed to load parent reading profile:', error); + } + } + + // Setup defaults and update signals + this.setupDefaultsFromProfile(readingProfile); + this.setupSettingsForm(); + + // Set initial theme + const themeName = readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme; + this.setTheme(themeName, false); + + // Mark as initialized - this will trigger effects to emit initial values + this._isInitialized.set(true); + } + + /** + * Setup default values and update signals from profile + */ + private setupDefaultsFromProfile(profile: ReadingProfile): void { + // Set defaults if undefined + if (profile.bookReaderFontFamily === undefined) { + profile.bookReaderFontFamily = 'default'; + } + if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) { + profile.bookReaderFontSize = 100; + } + if (profile.bookReaderLineSpacing === undefined || profile.bookReaderLineSpacing < 100) { + profile.bookReaderLineSpacing = 100; + } + if (profile.bookReaderMargin === undefined) { + profile.bookReaderMargin = 0; + } + if (profile.bookReaderReadingDirection === undefined) { + profile.bookReaderReadingDirection = ReadingDirection.LeftToRight; + } + if (profile.bookReaderWritingStyle === undefined) { + profile.bookReaderWritingStyle = WritingStyle.Horizontal; + } + if (profile.bookReaderLayoutMode === undefined) { + profile.bookReaderLayoutMode = BookPageLayoutMode.Default; + } + + // Update signals from profile + this._readingDirection.set(profile.bookReaderReadingDirection); + this._writingStyle.set(profile.bookReaderWritingStyle); + this._clickToPaginate.set(profile.bookReaderTapToPaginate); + this._layoutMode.set(profile.bookReaderLayoutMode); + this._immersiveMode.set(profile.bookReaderImmersiveMode); + + // Set up page styles + this.setPageStyles( + profile.bookReaderFontFamily, + profile.bookReaderFontSize + '%', + profile.bookReaderMargin + 'vw', + profile.bookReaderLineSpacing + '%' + ); + } + + /** + * Get the current settings form (for components that need direct form access) + */ + getSettingsForm(): FormGroup { + return this.settingsForm; + } + + /** + * Get current reading profile + */ + getCurrentReadingProfile(): ReadingProfile | null { + return this._currentReadingProfile(); + } + + /** + * Get font families for UI + */ + getFontFamilies(): FontFamily[] { + return this.fontFamilies; + } + + /** + * Get available themes + */ + getThemes(): BookTheme[] { + return bookColorThemes; + } + + /** + * Toggle reading direction + */ + toggleReadingDirection(): void { + const current = this._readingDirection(); + const newDirection = current === ReadingDirection.LeftToRight + ? ReadingDirection.RightToLeft + : ReadingDirection.LeftToRight; + + this._readingDirection.set(newDirection); + this.debouncedUpdateProfile(); + } + + /** + * Toggle writing style + */ + toggleWritingStyle(): void { + const current = this._writingStyle(); + const newStyle = current === WritingStyle.Horizontal + ? WritingStyle.Vertical + : WritingStyle.Horizontal; + + this._writingStyle.set(newStyle); + this.debouncedUpdateProfile(); + } + + /** + * Set theme + */ + setTheme(themeName: string, update: boolean = true): void { + const theme = bookColorThemes.find(t => t.name === themeName); + if (theme) { + this._activeTheme.set(theme); + if (update) { + this.debouncedUpdateProfile(); + } + } + } + + updateLayoutMode(mode: BookPageLayoutMode): void { + this._layoutMode.set(mode); + // Update form control to keep in sync + this.settingsForm.get('layoutMode')?.setValue(mode, { emitEvent: false }); + this.debouncedUpdateProfile(); + } + + updateClickToPaginate(value: boolean): void { + this._clickToPaginate.set(value); + this.settingsForm.get('bookReaderTapToPaginate')?.setValue(value, { emitEvent: false }); + this.debouncedUpdateProfile(); + } + + updateReadingDirection(value: ReadingDirection): void { + this._readingDirection.set(value); + this.debouncedUpdateProfile(); + } + + updateWritingStyle(value: WritingStyle) { + this._writingStyle.set(value); + this.debouncedUpdateProfile(); + } + + updateFullscreen(value: boolean) { + this._isFullscreen.set(value); + this.settingUpdateSubject.next({ setting: 'fullscreen', object: null }); // TODO: Refactor into an effect + } + + updateImmersiveMode(value: boolean): void { + this._immersiveMode.set(value); + if (value) { + this._clickToPaginate.set(true); + } + } + + // Debounced update method to prevent too many API calls + private updateTimeout: any; + private debouncedUpdateProfile(): void { + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + } + this.updateTimeout = setTimeout(() => { + this.updateImplicitProfile(); + }, 500); + } + + /** + * Emit fullscreen toggle event + */ + toggleFullscreen(): void { + this.updateFullscreen(!this._isFullscreen()); + } + + + /** + * Update parent reading profile preferences + */ + updateParentProfile(): void { + const currentRp = this._currentReadingProfile(); + const seriesId = this._currentSeriesId(); + if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit || !seriesId) { + return; + } + + this.readingProfileService.updateParentProfile(seriesId, this.packReadingProfile()) + .subscribe(newProfile => { + this._currentReadingProfile.set(newProfile); + this.toastr.success(translate('manga-reader.reading-profile-updated')); + }); + } + + /** + * Promote implicit profile to named profile + */ + promoteProfile(): Observable { + const currentRp = this._currentReadingProfile(); + if (!currentRp || currentRp.kind !== ReadingProfileKind.Implicit) { + throw new Error('Can only promote implicit profiles'); + } + + return this.readingProfileService.promoteProfile(currentRp.id).pipe( + tap(newProfile => { + this._currentReadingProfile.set(newProfile); + }) + ); + } + + + /** + * Update form controls from current signal values + */ + private updateFormFromSignals(): void { + const profile = this._currentReadingProfile(); + if (!profile) return; + + // Update form controls without triggering valueChanges + this.settingsForm.patchValue({ + bookReaderFontFamily: profile.bookReaderFontFamily, + bookReaderFontSize: profile.bookReaderFontSize, + bookReaderTapToPaginate: this._clickToPaginate(), + bookReaderLineSpacing: profile.bookReaderLineSpacing, + bookReaderMargin: profile.bookReaderMargin, + layoutMode: this._layoutMode(), + bookReaderImmersiveMode: this._immersiveMode() + }, { emitEvent: false }); + } + + /** + * Sets up the reactive form and bidirectional binding with signals + */ + private setupSettingsForm(): void { + const profile = this._currentReadingProfile(); + if (!profile) return; + + // Clear existing form + this.settingsForm = new FormGroup({}); + + // Add controls with current values + this.settingsForm.addControl('bookReaderFontFamily', new FormControl(profile.bookReaderFontFamily)); + this.settingsForm.addControl('bookReaderFontSize', new FormControl(profile.bookReaderFontSize)); + this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this._clickToPaginate())); + this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(profile.bookReaderLineSpacing)); + this.settingsForm.addControl('bookReaderMargin', new FormControl(profile.bookReaderMargin)); + this.settingsForm.addControl('layoutMode', new FormControl(this._layoutMode())); + this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this._immersiveMode())); + + // Set up value change subscriptions + this.setupFormSubscriptions(); + } + + /** + * Sets up form value change subscriptions to update signals + */ + private setupFormSubscriptions(): void { + // Font family changes + this.settingsForm.get('bookReaderFontFamily')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(fontName => { + this.isUpdatingFromForm = true; + + const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default'; + const currentStyles = this._pageStyles(); + + const newStyles = { ...currentStyles }; + if (familyName === 'default') { + newStyles['font-family'] = 'inherit'; + } else { + newStyles['font-family'] = `'${familyName}'`; + } + + this._pageStyles.set(newStyles); + this.isUpdatingFromForm = false; + }); + + // Font size changes + this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.isUpdatingFromForm = true; + + const currentStyles = this._pageStyles(); + const newStyles = { ...currentStyles }; + newStyles['font-size'] = value + '%'; + this._pageStyles.set(newStyles); + + this.isUpdatingFromForm = false; + }); + + // Tap to paginate changes + this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.isUpdatingFromForm = true; + this._clickToPaginate.set(value); + this.isUpdatingFromForm = false; + }); + + // Line spacing changes + this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.isUpdatingFromForm = true; + + const currentStyles = this._pageStyles(); + const newStyles = { ...currentStyles }; + newStyles['line-height'] = value + '%'; + this._pageStyles.set(newStyles); + + this.isUpdatingFromForm = false; + }); + + // Margin changes + this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.isUpdatingFromForm = true; + + const currentStyles = this._pageStyles(); + const newStyles = { ...currentStyles }; + newStyles['margin-left'] = value + 'vw'; + newStyles['margin-right'] = value + 'vw'; + this._pageStyles.set(newStyles); + + this.isUpdatingFromForm = false; + }); + + // Layout mode changes + this.settingsForm.get('layoutMode')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((layoutMode: BookPageLayoutMode) => { + this.isUpdatingFromForm = true; + this._layoutMode.set(layoutMode); + this.isUpdatingFromForm = false; + }); + + // Immersive mode changes + this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((immersiveMode: boolean) => { + this.isUpdatingFromForm = true; + + if (immersiveMode) { + this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true, { emitEvent: false }); + this._clickToPaginate.set(true); + } + this._immersiveMode.set(immersiveMode); + + this.isUpdatingFromForm = false; + }); + + // Update implicit profile on form changes (debounced) - ONLY source of profile updates + this.settingsForm.valueChanges.pipe( + debounceTime(500), // Increased debounce time + skip(1), // Skip initial form creation + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + // Only update if we're not currently updating from form changes + if (!this.isUpdatingFromForm) { + this.updateImplicitProfile(); + } + }); + } + + /** + * Resets a selection of settings to their default (Page Styles) + */ + resetSettings() { + const defaultStyles = this.getDefaultPageStyles(); + this.setPageStyles( + defaultStyles["font-family"], + defaultStyles["font-size"], + defaultStyles['margin-left'], + defaultStyles['line-height'], + ); + } + + // private emitInitialSettings(): void { + // // Emit all current settings so the reader can initialize properly + // this.settingUpdateSubject.next({ setting: 'pageStyle', object: this.pageStylesSubject.value }); + // this.settingUpdateSubject.next({ setting: 'clickToPaginate', object: this.clickToPaginateSubject.value }); + // this.settingUpdateSubject.next({ setting: 'layoutMode', object: this.layoutModeSubject.value }); + // this.settingUpdateSubject.next({ setting: 'readingDirection', object: this.readingDirectionSubject.value }); + // this.settingUpdateSubject.next({ setting: 'writingStyle', object: this.writingStyleSubject.value }); + // this.settingUpdateSubject.next({ setting: 'immersiveMode', object: this.immersiveModeSubject.value }); + // + // const activeTheme = this.activeThemeSubject.value; + // if (activeTheme) { + // this.settingUpdateSubject.next({ setting: 'theme', object: activeTheme }); + // } + // } + + private updateImplicitProfile(): void { + if (!this._currentReadingProfile() || !this._currentSeriesId()) return; + + this.readingProfileService.updateImplicit(this.packReadingProfile(), this._currentSeriesId()!) + .subscribe({ + next: newProfile => { + this._currentReadingProfile.set(newProfile); + }, + error: err => { + console.error('Failed to update implicit profile:', err); + } + }); + } + + /** + * Packs current settings into a ReadingProfile object + */ + private packReadingProfile(): ReadingProfile { + const currentProfile = this._currentReadingProfile(); + if (!currentProfile) { + throw new Error('No current reading profile'); + } + + const modelSettings = this.settingsForm.getRawValue(); + const data = { ...currentProfile }; + + // Update from form values + data.bookReaderFontFamily = modelSettings.bookReaderFontFamily; + data.bookReaderFontSize = modelSettings.bookReaderFontSize; + data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing; + data.bookReaderMargin = modelSettings.bookReaderMargin; + data.bookReaderTapToPaginate = this._clickToPaginate(); + data.bookReaderLayoutMode = this._layoutMode(); + data.bookReaderImmersiveMode = this._immersiveMode(); + + // Update from signals + data.bookReaderReadingDirection = this._readingDirection(); + data.bookReaderWritingStyle = this._writingStyle(); + + const activeTheme = this._activeTheme(); + if (activeTheme) { + data.bookReaderThemeName = activeTheme.name; + } + + console.log('packed reading profile:', data); + + return data; + } + + // private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void { + // const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth; + // const mobileBreakpointMarginOverride = 700; + // + // let defaultMargin = '15vw'; + // if (windowWidth <= mobileBreakpointMarginOverride) { + // defaultMargin = '5vw'; + // } + // + // const currentStyles = this.pageStylesSubject.value; + // const newStyles: PageStyle = { + // 'font-family': fontFamily || currentStyles['font-family'] || 'default', + // 'font-size': fontSize || currentStyles['font-size'] || '100%', + // 'margin-left': margin || currentStyles['margin-left'] || defaultMargin, + // 'margin-right': margin || currentStyles['margin-right'] || defaultMargin, + // 'line-height': lineHeight || currentStyles['line-height'] || '100%' + // }; + // + // this.pageStylesSubject.next(newStyles); + // this.updateImplicitProfile(); + // this.settingUpdateSubject.next({ setting: 'pageStyle', object: newStyles }); + // } + private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void { + const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth; + const mobileBreakpointMarginOverride = 700; + + let defaultMargin = '15vw'; + if (windowWidth <= mobileBreakpointMarginOverride) { + defaultMargin = '5vw'; + } + + const currentStyles = this._pageStyles(); + const newStyles: PageStyle = { + 'font-family': fontFamily || currentStyles['font-family'] || 'default', + 'font-size': fontSize || currentStyles['font-size'] || '100%', + 'margin-left': margin || currentStyles['margin-left'] || defaultMargin, + 'margin-right': margin || currentStyles['margin-right'] || defaultMargin, + 'line-height': lineHeight || currentStyles['line-height'] || '100%' + }; + + this._pageStyles.set(newStyles); + } + + public getDefaultPageStyles(): PageStyle { + return { + 'font-family': 'default', + 'font-size': '100%', + 'margin-left': '15vw', + 'margin-right': '15vw', + 'line-height': '100%' + }; + } + + + createNewProfileFromImplicit() { + const rp = this.getCurrentReadingProfile(); + if (rp === null || rp.kind !== ReadingProfileKind.Implicit) { + return; + } + + this.promoteProfile().subscribe(newProfile => { + this._currentReadingProfile.set(newProfile); + this._parentReadingProfile.set(newProfile); + this.toastr.success(translate("manga-reader.reading-profile-promoted")); + }); + } +} diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 52aef2a4a..04f659ef4 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -24,6 +24,8 @@ import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; import {FilterField} from "../_models/metadata/v2/filter-field"; +import {Annotation} from "../book-reader/_models/annotation"; +import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -222,6 +224,10 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/time-left?seriesId=' + seriesId); } + getTimeLeftForChapter(seriesId: number, chapterId: number) { + return this.httpClient.get(this.baseUrl + `reader/time-left-for-chapter?seriesId=${seriesId}&chapterId=${chapterId}`); + } + /** * Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes */ @@ -326,8 +332,16 @@ export class ReaderService { return this.httpClient.get>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId); } - createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) { - return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId}); + createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null, selectedText: string) { + return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText}); + } + + getAnnotations(chapterId: number) { + return this.httpClient.get>(this.baseUrl + 'reader/annotations?chapterId=' + chapterId); + } + + createAnnotation(data: CreateAnnotationRequest) { + return this.httpClient.post>(this.baseUrl + 'reader/create-annotation', data); } getElementFromXPath(path: string) { @@ -390,4 +404,5 @@ export class ReaderService { this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format), {queryParams: {incognitoMode}}); } + } diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html new file mode 100644 index 000000000..f9405b231 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html @@ -0,0 +1,25 @@ +
+ +
+
{{ annotationText() }}
+
+ {{ createdDate() | utcToLocaleDate | date:'short' }} +
+
+
+ +
+
+
diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss new file mode 100644 index 000000000..8341701ee --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.scss @@ -0,0 +1,49 @@ +// annotation-card.component.scss +.annotation-card { + position: absolute; + z-index: 1000; + width: 300px; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + padding: 12px; + font-size: 14px; + + .annotation-content { + .annotation-text { + font-weight: 500; + margin-bottom: 8px; + line-height: 1.4; + } + + .annotation-meta { + color: #6b7280; + border-top: 1px solid #f3f4f6; + padding-top: 8px; + } + } +} + +.connection-line { + position: absolute; + z-index: 999; + height: 2px; + background-color: #9ca3af; + transform-origin: 0 50%; + transition: opacity 0.2s ease; + + .connection-dot { + position: absolute; + right: -3px; + top: 50%; + width: 6px; + height: 6px; + background-color: #9ca3af; + border-radius: 50%; + transform: translateY(-50%); + } + + &.hovered { + opacity: 1 !important; + } +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts new file mode 100644 index 000000000..f12e6cbd3 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts @@ -0,0 +1,30 @@ +import {Component, input, model, output} from '@angular/core'; +import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe"; +import {DatePipe} from "@angular/common"; + +@Component({ + selector: 'app-annotation-card', + imports: [ + UtcToLocaleDatePipe, + DatePipe + ], + templateUrl: './annotation-card.component.html', + styleUrl: './annotation-card.component.scss' +}) +export class AnnotationCardComponent { + position = input.required(); + annotationText = input('This is test text'); + createdDate = input('01-01-0001'); + isHovered = model(false); + + mouseEnter = output(); + mouseLeave = output(); + + onMouseEnter() { + this.mouseEnter.emit(); + } + + onMouseLeave() { + this.mouseLeave.emit(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html new file mode 100644 index 000000000..a598b306a --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss new file mode 100644 index 000000000..309a3d86b --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.scss @@ -0,0 +1,104 @@ +.epub-highlight { + position: relative; + display: inline; + transition: all 0.2s ease-in-out; + + .epub-highlight-blue { + background-color: rgba(59, 130, 246, 0.3); + border-radius: 2px; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(59, 130, 246, 0.5); + } + } + + .epub-highlight-green { + background-color: rgba(34, 197, 94, 0.3); + border-radius: 2px; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(34, 197, 94, 0.5); + } + } +} + +// Global styles for annotation cards (since they're appended to body) +::ng-deep .annotation-card, +.annotation-card { + position: absolute; + z-index: 1000; + width: 200px; + //background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + padding: 12px; + font-size: 14px; + + &[data-position="left"] { + transform: translateX(0); + } + + &[data-position="right"] { + transform: translateX(0); + } + + .annotation-content { + .annotation-text { + font-weight: 500; + margin-bottom: 8px; + line-height: 1.4; + } + + .annotation-meta { + color: #6b7280; + border-top: 1px solid #f3f4f6; + padding-top: 8px; + } + } + + .connection-line { + position: absolute; + top: 50%; + width: 20px; + height: 2px; + background-color: #9ca3af; + transform: translateY(-50%); + opacity: 0.3; // Default low opacity + transition: opacity 0.2s ease; + + // Show line on hover + &.hovered { + opacity: 1; + } + + &[data-direction="left"] { + right: -20px; + } + + &[data-direction="right"] { + left: -20px; + } + + &::after { + content: ''; + position: absolute; + top: 50%; + width: 6px; + height: 6px; + background-color: #9ca3af; + border-radius: 50%; + transform: translateY(-50%); + } + + &[data-direction="left"]::after { + right: -3px; + } + + &[data-direction="right"]::after { + left: -3px; + } + } +} diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts new file mode 100644 index 000000000..160017ac8 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts @@ -0,0 +1,252 @@ +import { + AfterViewChecked, + Component, + ComponentRef, + computed, + ElementRef, + inject, + input, + model, + OnDestroy, + OnInit, + signal, + ViewChild +} from '@angular/core'; +import {Annotation} from "../../../_models/annotation"; +import {AnnotationCardComponent} from "../annotation-card/annotation-card.component"; +import {AnnotationCardService} from 'src/app/_service/annotation-card.service'; + +export type HighlightColor = 'blue' | 'green'; + +@Component({ + selector: 'app-epub-highlight', + imports: [], + templateUrl: './epub-highlight.component.html', + styleUrl: './epub-highlight.component.scss' +}) +export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestroy { + showHighlight = model(false); + color = input('blue'); + annotation = model.required(); + isHovered = signal(false); + + @ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef; + + private resizeObserver?: ResizeObserver; + private annotationCardElement?: HTMLElement; + private annotationCardRef?: ComponentRef; + + private annotationCardService = inject(AnnotationCardService); + + showAnnotationCard = computed(() => { + const annotation = this.annotation(); + return this.showHighlight() && true; //annotation && annotation?.noteText.length > 0; + }); + + highlightClasses = computed(() => { + const baseClass = 'epub-highlight'; + + if (!this.showHighlight()) { + return ''; + } + + const colorClass = `epub-highlight-${this.color()}`; + return `${colorClass}`; + }); + + cardPosition = computed(() => { + console.log('card position called') + if (!this.showHighlight() || !this.highlightSpan) return null; + + const rect = this.highlightSpan.nativeElement.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const cardWidth = 200; + const cardHeight = 80; // Approximate card height + + // Check if highlight is on left half (< 50%) or right half (>= 50%) of document + const highlightCenterX = rect.left + (rect.width / 2); + const isOnLeftHalf = highlightCenterX < (viewportWidth * 0.5); + + const cardLeft = isOnLeftHalf + ? Math.max(20, rect.left - cardWidth - 20) // Left side with margin consideration + : Math.min(viewportWidth - cardWidth - 20, rect.right + 20); // Right side + + const cardTop = rect.top + window.scrollY; + + // Calculate connection points + const highlightCenterY = rect.top + window.scrollY + (rect.height / 2); + const cardCenterY = cardTop + (cardHeight / 2); + + // Connection points + const highlightPoint = { + x: isOnLeftHalf ? rect.left : rect.right, + y: highlightCenterY + }; + + const cardPoint = { + x: isOnLeftHalf ? cardLeft + cardWidth : cardLeft, + y: cardCenterY + }; + + // Calculate line properties + const deltaX = cardPoint.x - highlightPoint.x; + const deltaY = cardPoint.y - highlightPoint.y; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI; + + return { + top: cardTop, + left: cardLeft, + isRight: !isOnLeftHalf, + connection: { + startX: highlightPoint.x, + startY: highlightPoint.y, + endX: cardPoint.x, + endY: cardPoint.y, + distance: distance, + angle: angle + } + }; + }); + + ngOnInit() { + // Monitor viewport changes for repositioning + this.resizeObserver = new ResizeObserver(() => { + // Trigger recalculation if card is visible + if (this.showAnnotationCard()) { + this.updateCardPosition(); + } + }); + this.resizeObserver.observe(document.body); + } + + ngAfterViewChecked() { + if (this.showAnnotationCard() && this.cardPosition()) { + this.createOrUpdateAnnotationCard(); + } else { + this.removeAnnotationCard(); + } + } + + ngOnDestroy() { + this.resizeObserver?.disconnect(); + } + + onMouseEnter() { + this.isHovered.set(true); + if (this.annotation() && this.showAnnotationCard()) { + //this.showAnnotationCard.update(true); + } + } + + onMouseLeave() { + this.isHovered.set(false); + //this.showAnnotationCard.set(false); + } + + + toggleHighlight() { + this.showHighlight.set(!this.showHighlight()); + } + + updateCardPosition() { + // TODO: Figure this out + } + + private createOrUpdateAnnotationCard() { + // const pos = this.cardPosition(); + // if (!pos) return; + // + // // Remove existing card if it exists + // this.removeAnnotationCard(); + // + // // Create new card element + // this.annotationCardElement = document.createElement('div'); + // this.annotationCardElement.className = `annotation-card ${this.isHovered() ? 'hovered' : ''}`; + // this.annotationCardElement.setAttribute('data-position', pos.isRight ? 'right' : 'left'); + // this.annotationCardElement.style.position = 'absolute'; + // this.annotationCardElement.style.top = `${pos.top}px`; + // this.annotationCardElement.style.left = `${pos.left}px`; + // this.annotationCardElement.style.zIndex = '1000'; + // + // // Add event listeners for hover + // this.annotationCardElement.addEventListener('mouseenter', () => this.onMouseEnter()); + // this.annotationCardElement.addEventListener('mouseleave', () => this.onMouseLeave()); + // + // // Create card content + // this.annotationCardElement.innerHTML = ` + //
+ //
This is test text
+ //
+ // 10/20/2025 + //
+ //
+ // `; + // + // // Create connection line + // const lineElement = document.createElement('div'); + // lineElement.className = `connection-line ${this.isHovered() ? 'hovered' : ''}`; + // lineElement.style.position = 'absolute'; + // lineElement.style.left = `${pos.connection.startX}px`; + // lineElement.style.top = `${pos.connection.startY}px`; + // lineElement.style.width = `${pos.connection.distance}px`; + // lineElement.style.height = '2px'; + // lineElement.style.backgroundColor = '#9ca3af'; + // lineElement.style.transformOrigin = '0 50%'; + // lineElement.style.transform = `rotate(${pos.connection.angle}deg)`; + // lineElement.style.opacity = this.isHovered() ? '1' : '0.3'; + // lineElement.style.transition = 'opacity 0.2s ease'; + // lineElement.style.zIndex = '999'; + // + // // Add dot at the end + // const dotElement = document.createElement('div'); + // dotElement.style.position = 'absolute'; + // dotElement.style.right = '-3px'; + // dotElement.style.top = '50%'; + // dotElement.style.width = '6px'; + // dotElement.style.height = '6px'; + // dotElement.style.backgroundColor = '#9ca3af'; + // dotElement.style.borderRadius = '50%'; + // dotElement.style.transform = 'translateY(-50%)'; + // + // lineElement.appendChild(dotElement); + // + // // Append to body + // document.body.appendChild(this.annotationCardElement); + // document.body.appendChild(lineElement); + // + // // Store reference to line for updates + // (this.annotationCardElement as any).lineElement = lineElement; + + const pos = this.cardPosition(); + if (!pos) return; + + // Only create if not already created + if (!this.annotationCardRef) { + this.annotationCardRef = this.annotationCardService.show({ + position: pos, + annotationText: this.annotation()?.comment, + createdDate: new Date('10/20/2025'), + onMouseEnter: () => this.onMouseEnter(), + onMouseLeave: () => this.onMouseLeave() + }); + } + } + + private removeAnnotationCard() { + // if (this.annotationCardElement) { + // // Remove associated line element + // const lineElement = (this.annotationCardElement as any).lineElement; + // if (lineElement) { + // lineElement.remove(); + // } + // + // this.annotationCardElement.remove(); + // this.annotationCardElement = undefined; + // } + if (this.annotationCardRef) { + this.annotationCardService.hide(); + this.annotationCardRef = undefined; + } + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html new file mode 100644 index 000000000..102cd9245 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html @@ -0,0 +1,14 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + Hello + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts new file mode 100644 index 000000000..6c36f78c5 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts @@ -0,0 +1,25 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model} from '@angular/core'; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {CreateAnnotationRequest} from "../../../_models/create-annotation-request"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-create-annotation-drawer', + imports: [ + TranslocoDirective + ], + templateUrl: './create-annotation-drawer.component.html', + styleUrl: './create-annotation-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateAnnotationDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + + createAnnotation = model(null); + + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html new file mode 100644 index 000000000..829fe9ad0 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.html @@ -0,0 +1,19 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ @let sId = seriesId(); + @let rp = readingProfile(); + @if (sId && rp) { + + } +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts new file mode 100644 index 000000000..ab35c8317 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/epub-setting-drawer/epub-setting-drawer.component.ts @@ -0,0 +1,83 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core'; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {ReaderSettingsComponent} from "../../reader-settings/reader-settings.component"; +import {ReadingProfile} from "../../../../_models/preferences/reading-profiles"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-epub-setting-drawer', + imports: [ + ReaderSettingsComponent, + TranslocoDirective + ], + templateUrl: './epub-setting-drawer.component.html', + styleUrl: './epub-setting-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EpubSettingDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + + chapterId = model(); + seriesId = model(); + readingProfile = model(); + +// updated = new EventEmitter(); + + + constructor() { + + effect(() => { + const id = this.chapterId(); + if (!id) { + console.error('You must pass chapterId'); + return; + } + }); + } + + // + // updateColorTheme(theme: BookTheme) { + // const evt = {setting: 'theme', object: theme} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // updateReaderStyles(pageStyles: PageStyle) { + // const evt = {setting: 'pageStyle', object: pageStyles} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // showPaginationOverlay(clickToPaginate: boolean) { + // const evt = {setting: 'clickToPaginate', object: clickToPaginate} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // toggleFullscreen() { + // const evt = {setting: 'fullscreen', object: null} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // updateWritingStyle(writingStyle: WritingStyle) { + // const evt = {setting: 'writingStyle', object: writingStyle} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // updateLayoutMode(mode: BookPageLayoutMode) { + // const evt = {setting: 'layoutMode', object: mode} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // updateReadingDirection(readingDirection: ReadingDirection) { + // const evt = {setting: 'readingDirection', object: readingDirection} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + // + // updateImmersiveMode(immersiveMode: boolean) { + // const evt = {setting: 'immersiveMode', object: immersiveMode} as ReaderSettingUpdate; + // this.updated.emit(evt); + // } + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.html new file mode 100644 index 000000000..a38a979e0 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.html @@ -0,0 +1,14 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + Hello + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.ts new file mode 100644 index 000000000..ce9c9c6f7 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component.ts @@ -0,0 +1,24 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-view-annotation-drawer', + imports: [ + TranslocoDirective + ], + templateUrl: './view-annotation-drawer.component.html', + styleUrl: './view-annotation-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewAnnotationDrawerComponent { + + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + + + close() { + this.activeOffcanvas.close(); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html new file mode 100644 index 000000000..027241557 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.html @@ -0,0 +1,26 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + @let items = bookmarks(); + @if (items) { + + +
+ @for(item of scroll.viewPortItems; let idx = $index; track item) { +
+ +
+ } +
+
+ } + +
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts new file mode 100644 index 000000000..9460edc9b --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts @@ -0,0 +1,49 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {ReaderService} from "../../../../_services/reader.service"; +import {PageBookmark} from "../../../../_models/readers/page-bookmark"; +import {ImageService} from "../../../../_services/image.service"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {ImageComponent} from "../../../../shared/image/image.component"; + +@Component({ + selector: 'app-view-bookmarks-drawer', + imports: [ + TranslocoDirective, + VirtualScrollerModule, + ImageComponent + ], + templateUrl: './view-bookmark-drawer.component.html', + styleUrl: './view-bookmark-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewBookmarkDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly readerService = inject(ReaderService); + protected readonly imageService = inject(ImageService); + + chapterId = model(); + bookmarks = model(); + + constructor() { + effect(() => { + const id = this.chapterId(); + if (!id) { + console.error('You must pass chapterId'); + return; + } + + this.readerService.getBookmarks(id).subscribe(bookmarks => { + this.bookmarks.set(bookmarks); + this.cdRef.markForCheck(); + }); + }); + } + + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html new file mode 100644 index 000000000..97665ff18 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.html @@ -0,0 +1,29 @@ + +
+
+ {{t('title')}} +
+ +
+ +
+ + +
+
+
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss new file mode 100644 index 000000000..5cec79d06 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.scss @@ -0,0 +1,6 @@ +// You must add this on a component based drawer +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts new file mode 100644 index 000000000..1ef77a8fd --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts @@ -0,0 +1,131 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + EventEmitter, + inject, + model +} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import { + NgbActiveOffcanvas, + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet +} from "@ng-bootstrap/ng-bootstrap"; +import { + PersonalTableOfContentsComponent, + PersonalToCEvent +} from "../../personal-table-of-contents/personal-table-of-contents.component"; +import {TableOfContentsComponent} from "../../table-of-contents/table-of-contents.component"; +import {BookChapterItem} from "../../../_models/book-chapter-item"; +import {BookService} from "../../../_services/book.service"; + + +enum TabID { + TableOfContents = 1, + PersonalTableOfContents = 2 +} + + +export interface LoadPageEvent { + pageNumber: number; + part: string; +} + + +@Component({ + selector: 'app-view-toc-drawer', + imports: [ + TranslocoDirective, + PersonalTableOfContentsComponent, + NgbNav, + NgbNavContent, + NgbNavLink, + TableOfContentsComponent, + NgbNavOutlet, + NgbNavItem + ], + templateUrl: './view-toc-drawer.component.html', + styleUrl: './view-toc-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewTocDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly bookService = inject(BookService); + + chapterId = model(); + + /** + * Sub Nav tab id + */ + tocId: TabID = TabID.TableOfContents; + /** + * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors + */ + chapters = model>([]); + /** + * Current Page + */ + pageNum = 0; + + /** + * A anchors that map to the page number. When you click on one of these, we will load a given page up for the user. + */ + pageAnchors: {[n: string]: number } = {}; + currentPageAnchor: string = ''; + + protected readonly TabID = TabID; + + /** + * Used to refresh the Personal PoC + */ + refreshPToC: EventEmitter = new EventEmitter(); + + loadPage: EventEmitter = new EventEmitter(); + + constructor() { + + effect(() => { + const id = this.chapterId(); + if (!id) { + console.error('You must pass chapterId'); + return; + } + + this.bookService.getBookChapters(id).subscribe(bookChapters => { + this.chapters.set(bookChapters); + this.cdRef.markForCheck(); + }); + }); + } + + /** + * From personal table of contents/bookmark + * @param event + */ + loadChapterPart(event: PersonalToCEvent) { + // this.setPageNum(event.pageNum); + // this.loadPage(event.scrollPart); + // TODO: Emit this event to let the main book reader handle + const evt = {pageNumber: event.pageNum, part:event.scrollPart} as LoadPageEvent; + this.loadPage.emit(evt); + } + + loadChapterPage(event: {pageNum: number, part: string}) { + // this.setPageNum(event.pageNum); + // this.loadPage('id("' + event.part + '")'); + // TODO: Emit this event to let the main book reader handle + const evt = {pageNumber: event.pageNum, part: `id("${event.part}")`} as LoadPageEvent; + this.loadPage.emit(evt); + } + + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html index f359c1767..1912a8978 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html @@ -1,46 +1,66 @@ -
+ @if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) { +
-
- - -
- -
-
- -
- -
- -
-
- -
-
- - -
-
- {{t('required-field')}} -
-
+
+ @switch (mode) { + @case (BookLineOverlayMode.None) { +
+
- - - + +
+ +
+ +
+ +
+ +
+ +
+ } + + @case (BookLineOverlayMode.Annotate) { + + } + + @case (BookLineOverlayMode.Bookmark) { +
+
+ + + @if (bookmarkForm.dirty || bookmarkForm.touched) { +
+ @if (bookmarkForm.get('name')?.errors?.required) { +
+ {{t('required-field')}} +
+ } +
+ } + +
+
+ } + } +
+ +
- - -
+ } diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 000a7fad2..081ea8fb0 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -1,13 +1,16 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, DestroyRef, - ElementRef, EventEmitter, HostListener, + ElementRef, + EventEmitter, + HostListener, inject, Input, - OnInit, Output, + OnInit, + Output, } from '@angular/core'; -import {CommonModule} from '@angular/common'; import {fromEvent, merge, of} from "rxjs"; import {catchError} from "rxjs/operators"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -16,15 +19,19 @@ import {ReaderService} from "../../../_services/reader.service"; import {ToastrService} from "ngx-toastr"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {KEY_CODES} from "../../../shared/_services/utility.service"; +import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; +import {CreateAnnotationRequest} from "../../_models/create-annotation-request"; +import {HightlightColor} from "../../_models/annotation"; enum BookLineOverlayMode { None = 0, - Bookmark = 1 + Annotate = 1, + Bookmark = 2 } @Component({ selector: 'app-book-line-overlay', - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective], + imports: [ReactiveFormsModule, TranslocoDirective], templateUrl: './book-line-overlay.component.html', styleUrls: ['./book-line-overlay.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -49,9 +56,12 @@ export class BookLineOverlayComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly cdRef = inject(ChangeDetectorRef); private readonly readerService = inject(ReaderService); + private readonly toastr = inject(ToastrService); + private readonly elementRef = inject(ElementRef); + private readonly epubMenuService = inject(EpubReaderMenuService); + + protected readonly BookLineOverlayMode = BookLineOverlayMode; - get BookLineOverlayMode() { return BookLineOverlayMode; } - constructor(private elementRef: ElementRef, private toastr: ToastrService) {} @HostListener('window:keydown', ['$event']) handleKeyPress(event: KeyboardEvent) { @@ -120,12 +130,32 @@ export class BookLineOverlayComponent implements OnInit { if (this.mode === BookLineOverlayMode.Bookmark) { this.bookmarkForm.get('name')?.setValue(this.selectedText); this.focusOnBookmarkInput(); + return; + } + + if (this.mode === BookLineOverlayMode.Annotate) { + // TODO: Open annotation drawer + this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.xPath, this.selectedText + const createAnnotation = { + chapterId: this.chapterId, + libraryId: this.libraryId, + volumeId: this.volumeId, + comment: null, + selectedText: this.selectedText, + containsSpoiler: false, + pageNumber: this.pageNumber, + xpath: this.xPath, + endingXPath: this.xPath, // TODO: Figure this out + highlightCount: this.selectedText.length, + hightlightColor: HightlightColor.Blue + } as CreateAnnotationRequest; + this.epubMenuService.openCreateAnnotationDrawer(createAnnotation); } } createPTOC() { this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, - this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => { + this.bookmarkForm.get('name')?.value, this.xPath, this.selectedText).pipe(catchError(err => { this.focusOnBookmarkInput(); return of(); })).subscribe(() => { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 69e4faf7b..b21b95804 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -1,171 +1,161 @@ -
- +
{{t('skip-header')}} - - - - -
-
{{t('title')}}
- {{t('close-reader')}} -
-
-
- - @if (layoutMode !== BookPageLayoutMode.Default) { - @let vp = getVirtualPage(); -
-
- {{t('page-label')}} -
-
- -
{{vp[0]}}
-
- -
-
{{vp[1]}}
- -
-
- } -
- {{t('pagination-header')}} -
-
- -
{{pageNum}}
-
- -
-
{{maxPages - 1}}
- -
-
-
-
- -
-
-
+ + @if (page !== undefined) { + + + }
-
+
- + @if (clickToPaginate() && !hidePagination) {
-
-
+ } -
-
- -
+
+ + @if (page !== undefined) { +
+ + @if ((scrollbarNeeded || layoutMode() !== BookPageLayoutMode.Default) && !(writingStyle() === WritingStyle.Vertical && layoutMode() === BookPageLayoutMode.Default)) { +
+ +
+ } + }
- -
- - - -
- @if(isLoading) { -
- {{t('loading-book')}} -
- } @else { - - ({{t('incognito-mode-label')}}) - {{bookTitle}} - } + + + + @if (!immersiveMode() || epubMenuService.isDrawerOpen() || actionBarVisible) { +
+ + + + +
+ @if (isLoading) { +
+ {{ t('loading-book') }} +
+ } @else { + @if (incognitoMode) { + + ({{ t('incognito-mode-label') }}) + + } + {{ bookTitle }} + } +
+ + +
+ @if (!this.adhocPageHistory.isEmpty()) { + + } + + + + +
- - -
+ } + + + + + + @if (!immersiveMode() || epubMenuService.isDrawerOpen() || actionBarVisible) { +
+ + + @if (!this.adhocPageHistory.isEmpty()) { + + } + + +
+ @if(isLoading) { + + } @else { + + + + + {{t('page-num-label', {page: pageNum()})}} / {{maxPages}} + + + {{t('completion-label', {percent: (pageNum() / maxPages) | percent})}} + + + @let timeLeft = readingTimeLeftResource.value(); + @if (timeLeft) { + , + + + {{timeLeft! | readTimeLeft:true }} + + } + + } +
+ + +
+ }
diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 8f45302c3..526353125 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -355,6 +355,10 @@ $pagination-opacity: 0; //$pagination-color: red; //$pagination-opacity: 0.7; +.kavita-scale-width::after { + content: ' '; +} + .right { position: absolute; @@ -367,6 +371,7 @@ $pagination-opacity: 0; border: none !important; opacity: 0; outline: none; + cursor: pointer; &.immersive { top: 0px; diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index ebfa82c7c..0d2665e12 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -3,19 +3,24 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, ElementRef, EventEmitter, HostListener, inject, Inject, + model, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, - ViewChild + resource, + Signal, + ViewChild, + ViewContainerRef } from '@angular/core'; -import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common'; +import {DOCUMENT, NgClass, NgStyle, NgTemplateOutlet, PercentPipe} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; import {ToastrService} from 'ngx-toastr'; import {forkJoin, fromEvent, merge, of} from 'rxjs'; @@ -38,38 +43,25 @@ import {LibraryService} from 'src/app/_services/library.service'; import {LibraryType} from 'src/app/_models/library/library'; import {BookTheme} from 'src/app/_models/preferences/book-theme'; import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; -import {PageStyle, ReaderSettingsComponent} from '../reader-settings/reader-settings.component'; +import {PageStyle} from '../reader-settings/reader-settings.component'; import {ThemeService} from 'src/app/_services/theme.service'; import {ScrollService} from 'src/app/_services/scroll.service'; import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {TableOfContentsComponent} from '../table-of-contents/table-of-contents.component'; -import { - NgbNav, - NgbNavContent, - NgbNavItem, - NgbNavItemRole, - NgbNavLink, - NgbNavOutlet, - NgbProgressbar, - NgbTooltip -} from '@ng-bootstrap/ng-bootstrap'; -import {DrawerComponent} from '../../../shared/drawer/drawer.component'; +import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component"; -import { - PersonalTableOfContentsComponent, - PersonalToCEvent -} from "../personal-table-of-contents/personal-table-of-contents.component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {ConfirmService} from "../../../shared/confirm.service"; - - -enum TabID { - Settings = 1, - TableOfContents = 2, - PersonalTableOfContents = 3 -} +import {EpubHighlightComponent} from "../_annotations/epub-highlight/epub-highlight.component"; +import {Annotation} from "../../_models/annotation"; +import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; +import {LoadPageEvent} from "../_drawers/view-toc-drawer/view-toc-drawer.component"; +import {EpubReaderSettingsService, ReaderSettingUpdate} from "../../../_services/epub-reader-settings.service"; +import {ColumnLayoutClassPipe} from "../../_pipes/column-layout-class.pipe"; +import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe"; +import {ChapterService} from "../../../_services/chapter.service"; +import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe"; interface HistoryPoint { @@ -112,9 +104,8 @@ const elementLevelStyles = ['line-height', 'font-family']; transition('false <=> true', animate('4000ms')) ]) ], - imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, - NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, - BookLineOverlayComponent, PersonalTableOfContentsComponent, TranslocoDirective] + imports: [NgTemplateOutlet, NgStyle, NgClass, NgbTooltip, + BookLineOverlayComponent, TranslocoDirective, ColumnLayoutClassPipe, WritingStyleClassPipe, ReadTimeLeftPipe, PercentPipe] }) export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -122,6 +113,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly router = inject(Router); private readonly seriesService = inject(SeriesService); private readonly readerService = inject(ReaderService); + private readonly chapterService = inject(ChapterService); private readonly renderer = inject(Renderer2); private readonly navService = inject(NavService); private readonly toastr = inject(ToastrService); @@ -134,10 +126,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly themeService = inject(ThemeService); private readonly confirmService = inject(ConfirmService); private readonly cdRef = inject(ChangeDetectorRef); + protected readonly epubMenuService = inject(EpubReaderMenuService); + protected readonly readerSettingsService = inject(EpubReaderSettingsService); protected readonly BookPageLayoutMode = BookPageLayoutMode; protected readonly WritingStyle = WritingStyle; - protected readonly TabID = TabID; protected readonly ReadingDirection = ReadingDirection; protected readonly PAGING_DIRECTION = PAGING_DIRECTION; @@ -170,7 +163,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Current Page */ - pageNum = 0; + pageNum = model(0); /** * Max Pages */ @@ -189,18 +182,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * The current page only contains an image. This is used to determine if we should show the image in the center of the screen. */ isSingleImagePage = false; - /** - * Belongs to the drawer component - */ - activeTabId: TabID = TabID.Settings; - /** - * Sub Nav tab id - */ - tocId: TabID = TabID.TableOfContents; - /** - * Belongs to drawer component - */ - drawerOpen = false; /** * If the word/line overlay is open */ @@ -212,7 +193,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Book reader setting that hides the menuing system */ - immersiveMode: boolean = false; + //immersiveMode = model(false); /** * If we are loading from backend */ @@ -265,11 +246,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ nextPageDisabled = false; - /** - * Internal property used to capture all the different css properties to render on all elements. This is a cached version that is updated from reader-settings component - */ - pageStyles!: PageStyle; - /** * Offset for drawer and rendering canvas. Fixed to 62px. */ @@ -280,14 +256,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ horizontalScrollbarNeeded = false; scrollbarNeeded = false; - readingDirection: ReadingDirection = ReadingDirection.LeftToRight; - clickToPaginate = false; /** * Used solely for fullscreen to apply a hack */ - darkMode = true; + darkMode = model(true); + readingTimeLeftResource = resource({ + request: () => ({ + chapterId: this.chapterId, + seriesId: this.seriesId, + pageNumber: this.pageNum(), + }), + loader: async ({ request }) => { + return this.readerService.getTimeLeftForChapter(this.seriesId, this.chapterId).toPromise(); + } + }); + /** - * A anchors that map to the page number. When you click on one of these, we will load a given page up for the user. + * Anchors that map to the page number. When you click on one of these, we will load a given page up for the user. */ pageAnchors: {[n: string]: number } = {}; currentPageAnchor: string = ''; @@ -304,16 +289,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ isFullscreen: boolean = false; - /** - * How to render the page content - */ - layoutMode: BookPageLayoutMode = BookPageLayoutMode.Default; /** * Width of the document (in non-column layout), used for column layout virtual paging */ - windowWidth: number = 0; - windowHeight: number = 0; + windowWidth = model(0); + windowHeight = model(0); /** * used to track if a click is a drag or not, for opening menu @@ -328,7 +309,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; - writingStyle: WritingStyle = WritingStyle.Horizontal; + //writingStyle: WritingStyle = WritingStyle.Horizontal; /** @@ -336,6 +317,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ hidePagination = false; + annotations: Array = []; + /** * Used to refresh the Personal PoC */ @@ -351,12 +334,25 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; @ViewChild('reader', {static: false}) reader!: ElementRef; + @ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef; + + + protected readonly layoutMode = this.readerSettingsService.layoutMode; + protected readonly pageStyles = this.readerSettingsService.pageStyles; + protected readonly immersiveMode = this.readerSettingsService.immersiveMode; + protected readonly readingDirection = this.readerSettingsService.readingDirection; + protected readonly writingStyle = this.readerSettingsService.writingStyle; + protected readonly clickToPaginate = this.readerSettingsService.clickToPaginate; + + protected columnWidth!: Signal; + protected columnHeight!: Signal; + protected verticalBookContentWidth!: Signal; /** * Disables the Left most button */ get IsPrevDisabled(): boolean { - if (this.readingDirection === ReadingDirection.LeftToRight) { + if (this.readingDirection() === ReadingDirection.LeftToRight) { // Acting as Previous button return this.isPrevPageDisabled(); } @@ -366,7 +362,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } get IsNextDisabled(): boolean { - if (this.readingDirection === ReadingDirection.LeftToRight) { + if (this.readingDirection() === ReadingDirection.LeftToRight) { // Acting as Next button return this.isNextPageDisabled(); } @@ -376,8 +372,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { isNextPageDisabled() { const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); - const condition = (this.nextPageDisabled || this.nextChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum + 1 > this.maxPages - 1; - if (this.layoutMode !== BookPageLayoutMode.Default) { + const condition = (this.nextPageDisabled || this.nextChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum() + 1 > this.maxPages - 1; + if (this.layoutMode() !== BookPageLayoutMode.Default) { return condition && currentVirtualPage === totalVirtualPages; } return condition; @@ -385,8 +381,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { isPrevPageDisabled() { const [currentVirtualPage,,] = this.getVirtualPage(); - const condition = (this.prevPageDisabled || this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum === 0; - if (this.layoutMode !== BookPageLayoutMode.Default) { + const condition = (this.prevPageDisabled || this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum() === 0; + if (this.layoutMode() !== BookPageLayoutMode.Default) { return condition && currentVirtualPage === 0; } return condition; @@ -396,99 +392,53 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Determines if we show >> or > */ get IsNextChapter(): boolean { - if (this.layoutMode === BookPageLayoutMode.Default) { - return this.pageNum + 1 >= this.maxPages; + if (this.layoutMode() === BookPageLayoutMode.Default) { + return this.pageNum() + 1 >= this.maxPages; } const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); - if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum() + 1 >= this.maxPages; - return this.pageNum + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages); + return this.pageNum() + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages); } /** * Determines if we show << or < */ get IsPrevChapter(): boolean { - if (this.layoutMode === BookPageLayoutMode.Default) { - return this.pageNum === 0; + if (this.layoutMode() === BookPageLayoutMode.Default) { + return this.pageNum() === 0; } const [currentVirtualPage,,] = this.getVirtualPage(); - if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum() + 1 >= this.maxPages; - return this.pageNum === 0 && (currentVirtualPage === 0); - } - - get ColumnWidth() { - const base = this.writingStyle === WritingStyle.Vertical ? this.windowHeight : this.windowWidth; - switch (this.layoutMode) { - case BookPageLayoutMode.Default: - return 'unset'; - case BookPageLayoutMode.Column1: - return ((base / 2) - 4) + 'px'; - case BookPageLayoutMode.Column2: - return (base / 4) + 'px'; - default: - return 'unset'; - } - } - - get ColumnHeight() { - if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle === WritingStyle.Vertical) { - // Take the height after page loads, subtract the top/bottom bar - const height = this.windowHeight - (this.topOffset * 2); - return height + 'px'; - } - return 'unset'; - } - - get VerticalBookContentWidth() { - if (this.layoutMode !== BookPageLayoutMode.Default && this.writingStyle !== WritingStyle.Horizontal ) { - const width = this.getVerticalPageWidth() - return width + 'px'; - } - return ''; - } - - get ColumnLayout() { - switch (this.layoutMode) { - case BookPageLayoutMode.Default: - return ''; - case BookPageLayoutMode.Column1: - return 'column-layout-1'; - case BookPageLayoutMode.Column2: - return 'column-layout-2'; - } - } - - get WritingStyleClass() { - switch (this.writingStyle) { - case WritingStyle.Horizontal: - return ''; - case WritingStyle.Vertical: - return 'writing-style-vertical'; - } + return this.pageNum() === 0 && (currentVirtualPage === 0); } + get PageWidthForPagination() { - if (this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { + if (this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { return 'unset'; } return '100%' } get PageHeightForPagination() { - if (this.layoutMode === BookPageLayoutMode.Default) { + const layoutMode = this.layoutMode(); + const immersiveMode = this.immersiveMode(); + const widthHeight = this.windowHeight(); + + if (layoutMode=== BookPageLayoutMode.Default) { // if the book content is less than the height of the container, override and return height of container for pagination area if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) { return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px'; } - return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (this.immersiveMode ? 0 : 1)) * 2) + 'px'; + return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px'; } - if (this.immersiveMode) return this.windowHeight + 'px'; - return (this.windowHeight) - (this.topOffset * 2) + 'px'; + if (immersiveMode) return widthHeight + 'px'; + return (widthHeight) - (this.topOffset * 2) + 'px'; } constructor(@Inject(DOCUMENT) private document: Document) { @@ -496,6 +446,49 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.navService.hideSideNav(); this.themeService.clearThemes(); this.cdRef.markForCheck(); + + this.columnWidth = computed(() => { + const base = this.writingStyle() === WritingStyle.Vertical ? this.windowHeight() : this.windowWidth(); + switch (this.layoutMode()) { + case BookPageLayoutMode.Default: + return 'unset'; + case BookPageLayoutMode.Column1: + return ((base / 2) - 4) + 'px'; + case BookPageLayoutMode.Column2: + return (base / 4) + 'px'; + default: + return 'unset'; + } + }); + + this.columnHeight = computed(() => { + // Note: Computed signals need to be called before if statement to ensure it's called when a dep signal is updated + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + const windowHeight = this.windowHeight(); + + + if (layoutMode !== BookPageLayoutMode.Default || writingStyle === WritingStyle.Vertical) { + // Take the height after page loads, subtract the top/bottom bar + const height = windowHeight - (this.topOffset * 2); + return height + 'px'; + } + return 'unset'; + }); + + this.verticalBookContentWidth = computed(() => { + const layoutMode = this.layoutMode(); + const writingStyle = this.writingStyle(); + const pageStyles = this.pageStyles(); // Needed in inner method (not sure if Signals handle) + + + if (layoutMode !== BookPageLayoutMode.Default && writingStyle !== WritingStyle.Horizontal ) { + const width = this.getVerticalPageWidth() + return width + 'px'; + } + return ''; + }); + } /** @@ -543,7 +536,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }) ) .subscribe(); - } handleScrollEvent() { @@ -572,14 +564,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } saveProgress() { - let tempPageNum = this.pageNum; - if (this.pageNum == this.maxPages - 1) { - tempPageNum = this.pageNum + 1; + let tempPageNum = this.pageNum(); + if (this.pageNum() == this.maxPages - 1) { + tempPageNum = this.pageNum() + 1; } if (!this.incognitoMode) { this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); } + + // TODO: TEMP: Trigger reading time calculation update + // this.readerService.getTimeLeftForChapter(this.seriesId, this.chapterId).subscribe(c => { + // this.readingTimeLeft.set(c); + // }); + } ngOnDestroy(): void { @@ -598,7 +596,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.navService.showSideNav(); } - ngOnInit(): void { + async ngOnInit() { const libraryId = this.route.snapshot.paramMap.get('libraryId'); const seriesId = this.route.snapshot.paramMap.get('seriesId'); const chapterId = this.route.snapshot.paramMap.get('chapterId'); @@ -620,7 +618,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.cdRef.markForCheck(); - this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (data) => { this.readingProfile = data['readingProfile']; this.cdRef.markForCheck(); @@ -631,16 +629,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => { if (!hasProgress) { - this.toggleDrawer(); + this.toggleDrawer(); // TODO: Remove toggling drawer on first load this.toastr.info(translate('toasts.book-settings-info')); } }); - this.init(); + await this.init(); }); } - init() { + async init() { this.nextChapterId = CHAPTER_ID_NOT_FETCHED; this.prevChapterId = CHAPTER_ID_NOT_FETCHED; this.nextChapterDisabled = false; @@ -649,17 +647,26 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); - this.bookService.getBookInfo(this.chapterId).subscribe(info => { + this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => { if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { // Redirect to the manga reader. const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); - this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); + await this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); return; } this.bookTitle = info.bookTitle; this.cdRef.markForCheck(); + await this.readerSettingsService.initialize(this.seriesId, this.readingProfile); + + // Ensure any changes in the reader settings are applied to the reader + this.readerSettingsService.settingUpdates$.pipe( + takeUntilDestroyed(this.destroyRef), + tap((update) => this.handleReaderSettingsUpdate(update)) + ).subscribe(); + + forkJoin({ chapter: this.seriesService.getChapter(this.chapterId), progress: this.readerService.getProgress(this.chapterId), @@ -669,7 +676,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.volumeId = results.chapter.volumeId; this.maxPages = results.chapter.pages; this.chapters = results.chapters; - this.pageNum = results.progress.pageNum; + this.pageNum.set(results.progress.pageNum); this.cdRef.markForCheck(); if (results.progress.bookScrollId) { @@ -684,8 +691,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateImageSizes(); - if (this.pageNum >= this.maxPages) { - this.pageNum = this.maxPages - 1; + if (this.pageNum() >= this.maxPages) { + this.pageNum.set(this.maxPages - 1); this.cdRef.markForCheck(); this.saveProgress(); } @@ -698,7 +705,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); return; } - this.setPageNum(this.pageNum); + this.setPageNum(this.pageNum()); }); this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; @@ -708,7 +715,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); return; } - this.setPageNum(this.pageNum); + this.setPageNum(this.pageNum()); }); // Check if user progress has part, if so load it so we scroll to it @@ -730,7 +737,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateImageSizes(); const resumeElement = this.getFirstVisibleElementXPath(); - if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + if (this.layoutMode() !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { this.scrollTo(resumeElement); // This works pretty well, but not perfect } } @@ -742,9 +749,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (isInputFocused) return; if (event.key === KEY_CODES.RIGHT_ARROW) { - this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); } else if (event.key === KEY_CODES.LEFT_ARROW) { - this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); } else if (event.key === KEY_CODES.ESC_KEY) { const isHighlighting = window.getSelection()?.toString() != ''; if (isHighlighting) return; @@ -756,13 +763,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.G) { await this.goToPage(); } else if (event.key === KEY_CODES.F) { - this.toggleFullscreen() + this.applyFullscreen() } } onWheel(event: WheelEvent) { // This allows the user to scroll the page horizontally without holding shift - if (this.layoutMode !== BookPageLayoutMode.Default || this.writingStyle !== WritingStyle.Vertical) { + if (this.layoutMode() !== BookPageLayoutMode.Default || this.writingStyle() !== WritingStyle.Vertical) { return; } if (event.deltaY !== 0) { @@ -858,19 +865,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadChapterPage(event: {pageNum: number, part: string}) { - this.setPageNum(event.pageNum); - this.loadPage('id("' + event.part + '")'); - } - - /** - * From personal table of contents/bookmark - * @param event - */ - loadChapterPart(event: PersonalToCEvent) { - this.setPageNum(event.pageNum); - this.loadPage(event.scrollPart); - } /** * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value @@ -888,12 +882,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (!targetElem.attributes.hasOwnProperty('kavita-page')) { return; } const page = parseInt(targetElem.attributes['kavita-page'].value, 10); - if (this.adhocPageHistory.peek()?.page !== this.pageNum) { - this.adhocPageHistory.push({page: this.pageNum, scrollPart: this.lastSeenScrollPartPath}); + if (this.adhocPageHistory.peek()?.page !== this.pageNum()) { + this.adhocPageHistory.push({page: this.pageNum(), scrollPart: this.lastSeenScrollPartPath}); } const partValue = targetElem.attributes.hasOwnProperty('kavita-part') ? targetElem.attributes['kavita-part'].value : undefined; - if (partValue && page === this.pageNum) { + if (partValue && page === this.pageNum()) { this.scrollTo(targetElem.attributes['kavita-part'].value); return; } @@ -933,7 +927,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { page = parseInt(goToPageNum.trim(), 10); } - if (page === undefined || this.pageNum === page) { return; } + if (page === undefined || this.pageNum() === page) { return; } if (page > this.maxPages - 1) { page = this.maxPages - 1; @@ -941,7 +935,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { page = 0; } - this.pageNum = page; + this.pageNum.set(page); this.loadPage(); } @@ -952,7 +946,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.isLoading = true; this.cdRef.markForCheck(); - this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { + this.bookService.getBookPage(this.chapterId, this.pageNum()).pipe(take(1)).subscribe(content => { this.isSingleImagePage = this.checkSingleImagePage(content) // This needs be performed before we set this.page to avoid image jumping this.updateSingleImagePageStyles(); this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage @@ -961,7 +955,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setTimeout(() => { this.addLinkClickHandlers(); - this.updateReaderStyles(this.pageStyles); + this.applyPageStyles(this.pageStyles()); const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); if (imgs === null || imgs.length === 0) { @@ -984,11 +978,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Updates the image properties to fit the current layout mode and screen size */ updateImageSizes() { - const isVerticalWritingStyle = this.writingStyle === WritingStyle.Vertical; - const height = this.windowHeight - (this.topOffset * 2); + const isVerticalWritingStyle = this.writingStyle() === WritingStyle.Vertical; + const height = this.windowHeight() - (this.topOffset * 2); let maxHeight = 'unset'; let maxWidth = ''; - switch (this.layoutMode) { + switch (this.layoutMode()) { case BookPageLayoutMode.Default: if (isVerticalWritingStyle) { maxHeight = `${height}px`; @@ -1017,16 +1011,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateSingleImagePageStyles() { - if (this.isSingleImagePage && this.layoutMode !== BookPageLayoutMode.Default) { + if (this.isSingleImagePage && this.layoutMode() !== BookPageLayoutMode.Default) { this.document.documentElement.style.setProperty('--book-reader-content-position', 'absolute'); this.document.documentElement.style.setProperty('--book-reader-content-top', '50%'); this.document.documentElement.style.setProperty('--book-reader-content-left', '50%'); this.document.documentElement.style.setProperty('--book-reader-content-transform', 'translate(-50%, -50%)'); } else { - this.document.documentElement.style.setProperty('--book-reader-content-position', ''); - this.document.documentElement.style.setProperty('--book-reader-content-top', ''); - this.document.documentElement.style.setProperty('--book-reader-content-left', ''); - this.document.documentElement.style.setProperty('--book-reader-content-transform', ''); + this.document.documentElement.style.setProperty('--book-reader-content-position', ''); + this.document.documentElement.style.setProperty('--book-reader-content-top', ''); + this.document.documentElement.style.setProperty('--book-reader-content-left', ''); + this.document.documentElement.style.setProperty('--book-reader-content-transform', ''); } } @@ -1054,7 +1048,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Virtual Paging stuff this.updateWidthAndHeightCalcs(); - this.updateLayoutMode(this.layoutMode); + this.applyLayoutMode(this.layoutMode()); this.addEmptyPageIfRequired(); // Find all the part ids and their top offset @@ -1065,13 +1059,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.scrollTo(part); } else if (scrollTop !== undefined && scrollTop !== 0) { setTimeout(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); - } else if ((this.writingStyle === WritingStyle.Vertical) && (this.layoutMode === BookPageLayoutMode.Default)) { + } else if ((this.writingStyle() === WritingStyle.Vertical) && (this.layoutMode() === BookPageLayoutMode.Default)) { setTimeout(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement)); } else { - if (this.layoutMode === BookPageLayoutMode.Default) { + if (this.layoutMode() === BookPageLayoutMode.Default) { setTimeout(() => this.scrollService.scrollTo(0, this.reader.nativeElement)); - } else if (this.writingStyle === WritingStyle.Vertical) { + } else if (this.writingStyle() === WritingStyle.Vertical) { if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { setTimeout(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto')); } else { @@ -1093,10 +1087,48 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.saveProgress(); this.isLoading = false; this.cdRef.markForCheck(); + + this.readerService.getAnnotations(this.chapterId).subscribe(annotations => { + this.annotations = annotations; + this.setupAnnotationElements(); + this.cdRef.markForCheck(); + }); + + + } + + private setupAnnotationElements() { + + const annoationMap: {[key: number]: Annotation} = this.annotations.reduce((map, obj) => { + // @ts-ignore + map[obj.id] = obj; + return map; + }, {}); + + // Make the highlight components "real" + const highlightElems = this.document.querySelectorAll('app-epub-highlight'); + + for (let i = 0; i < highlightElems.length; i++) { + const highlight = highlightElems[i]; + const idAttr = highlight.getAttribute('id'); + + // Don't allow highlight injection unless the id is present + if (!idAttr) continue; + + + const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10); + const componentRef = this.readingContainer.createComponent(EpubHighlightComponent, + {projectableNodes: [[document.createTextNode(highlight.innerHTML)]]}); + if (highlight.parentNode != null) { + highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight); + } + + componentRef.instance.annotation.set(annoationMap[annotationId]); + } } private addEmptyPageIfRequired(): void { - if (this.layoutMode !== BookPageLayoutMode.Column2 || this.isSingleImagePage) { + if (this.layoutMode() !== BookPageLayoutMode.Column2 || this.isSingleImagePage) { return; } @@ -1114,7 +1146,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const emptyPage = this.renderer.createElement('div'); this.renderer.setStyle(emptyPage, 'height', columnHeight + 'px'); - this.renderer.setStyle(emptyPage, 'width', this.ColumnWidth); + this.renderer.setStyle(emptyPage, 'width', this.columnHeight()); this.renderer.appendChild(this.bookContentElemRef.nativeElement, emptyPage); } @@ -1130,28 +1162,32 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setPageNum(pageNum: number) { - this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); + this.pageNum.set(Math.max(Math.min(pageNum, this.maxPages), 0)); this.cdRef.markForCheck(); - if (this.pageNum >= this.maxPages - 10) { + if (this.pageNum() >= this.maxPages - 10) { // Tell server to cache the next chapter if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1), catchError(err => { this.nextChapterDisabled = true; + console.error(err); this.cdRef.markForCheck(); return of(null); })).subscribe(res => { this.nextChapterPrefetched = true; + this.cdRef.markForCheck(); }); } - } else if (this.pageNum <= 10) { + } else if (this.pageNum() <= 10) { if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1), catchError(err => { this.prevChapterDisabled = true; + console.error(err); this.cdRef.markForCheck(); return of(null); })).subscribe(res => { this.prevChapterPrefetched = true; + this.cdRef.markForCheck(); }); } } @@ -1171,17 +1207,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } prevPage() { - const oldPageNum = this.pageNum; + const oldPageNum = this.pageNum(); this.pagingDirection = PAGING_DIRECTION.BACKWARDS; // We need to handle virtual paging before we increment the actual page - if (this.layoutMode !== BookPageLayoutMode.Default) { + if (this.layoutMode() !== BookPageLayoutMode.Default) { const [currentVirtualPage, _, pageWidth] = this.getVirtualPage(); if (currentVirtualPage > 1) { // -2 apparently goes back 1 virtual page... - if (this.writingStyle === WritingStyle.Vertical) { + if (this.writingStyle() === WritingStyle.Vertical) { this.scrollService.scrollTo((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement, 'auto'); } else { this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement); @@ -1191,7 +1227,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - this.setPageNum(this.pageNum - 1); + this.setPageNum(this.pageNum() - 1); if (oldPageNum === 0) { // Move to next volume/chapter automatically @@ -1199,7 +1235,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } - if (oldPageNum === this.pageNum) { return; } + if (oldPageNum === this.pageNum()) { return; } this.loadPage(); } @@ -1211,12 +1247,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pagingDirection = PAGING_DIRECTION.FORWARD; // We need to handle virtual paging before we increment the actual page - if (this.layoutMode !== BookPageLayoutMode.Default) { + if (this.layoutMode() !== BookPageLayoutMode.Default) { const [currentVirtualPage, totalVirtualPages, pageWidth] = this.getVirtualPage(); if (currentVirtualPage < totalVirtualPages) { // +0 apparently goes forward 1 virtual page... - if (this.writingStyle === WritingStyle.Vertical) { + if (this.writingStyle() === WritingStyle.Vertical) { this.scrollService.scrollTo( (currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement, 'auto'); } else { this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement); @@ -1226,7 +1262,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - const oldPageNum = this.pageNum; + const oldPageNum = this.pageNum(); if (oldPageNum + 1 === this.maxPages) { // Move to next volume/chapter automatically this.loadNextChapter(); @@ -1234,9 +1270,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - this.setPageNum(this.pageNum + 1); + this.setPageNum(this.pageNum() + 1); - if (oldPageNum === this.pageNum) { return; } + if (oldPageNum === this.pageNum()) { return; } this.loadPage(); } @@ -1247,19 +1283,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ getPageWidth() { if (this.readingSectionElemRef == null) return 0; - const margin = (this.convertVwToPx(parseInt(this.pageStyles['margin-left'], 10)) * 2); + const margin = (this.convertVwToPx(parseInt(this.pageStyles()['margin-left'], 10)) * 2); return this.readingSectionElemRef.nativeElement.clientWidth - margin + COLUMN_GAP; } getPageHeight() { if (this.readingSectionElemRef == null) return 0; - const height = (parseInt(this.ColumnHeight.replace('px', ''), 10)); + const height = (parseInt(this.columnHeight().replace('px', ''), 10)); return height - COLUMN_GAP; } getVerticalPageWidth() { - const margin = (window.innerWidth * (parseInt(this.pageStyles['margin-left'], 10) / 100)) * 2; + if (!(this.pageStyles() || {}).hasOwnProperty('margin-left')) return 0; // TODO: Test this, added for safety during refactor + + const margin = (window.innerWidth * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2; const windowWidth = window.innerWidth || document.documentElement.clientWidth; return windowWidth - margin; } @@ -1298,17 +1336,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private getScrollOffsetAndTotalScroll() { const { nativeElement: bookContent } = this.bookContentElemRef; - const scrollOffset = this.writingStyle === WritingStyle.Vertical + const scrollOffset = this.writingStyle() === WritingStyle.Vertical ? bookContent.scrollTop : bookContent.scrollLeft; - const totalScroll = this.writingStyle === WritingStyle.Vertical + const totalScroll = this.writingStyle() === WritingStyle.Vertical ? bookContent.scrollHeight : bookContent.scrollWidth; return [scrollOffset, totalScroll]; } private getPageSize() { - return this.writingStyle === WritingStyle.Vertical + return this.writingStyle() === WritingStyle.Vertical ? this.getPageHeight() : this.getPageWidth(); } @@ -1316,7 +1354,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFirstVisibleElementXPath() { let resumeElement: string | null = null; - if (this.bookContentElemRef === null) return null; + if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null; const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) .filter(element => !element.classList.contains('no-observe')) @@ -1340,8 +1378,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Applies styles onto the html of the book page */ - updateReaderStyles(pageStyles: PageStyle) { - this.pageStyles = pageStyles; + applyPageStyles(pageStyles: PageStyle) { if (this.bookContentElemRef === undefined || !this.bookContentElemRef.nativeElement) return; // Before we apply styles, let's get an element on the screen so we can scroll to it after any shifts @@ -1353,7 +1390,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Line Height must be placed on each element in the page // Apply page level overrides - Object.entries(this.pageStyles).forEach(item => { + Object.entries(this.pageStyles()).forEach(item => { if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { // Remove the style or skip this.renderer.removeStyle(this.bookContentElemRef.nativeElement, item[0]); @@ -1379,7 +1416,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // After layout shifts, we need to refocus the scroll bar - if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + if (this.layoutMode() !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { this.updateWidthAndHeightCalcs(); this.scrollTo(resumeElement); // This works pretty well, but not perfect } @@ -1389,11 +1426,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Applies styles and classes that control theme * @param theme */ - updateColorTheme(theme: BookTheme) { + applyColorTheme(theme: BookTheme) { // Remove all themes Array.from(this.document.querySelectorAll('style[id^="brtheme-"]')).forEach(elem => elem.remove()); - this.darkMode = theme.isDarkTheme; + this.darkMode.set(theme.isDarkTheme); + this.cdRef.markForCheck(); const styleElem = this.renderer.createElement('style'); styleElem.id = theme.selector; @@ -1406,8 +1444,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateWidthAndHeightCalcs() { - this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight); - this.windowWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth); + this.windowHeight.set(Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight)); + this.windowWidth.set(Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth)); // Recalculate if bottom action bar is needed this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; @@ -1415,10 +1453,45 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); } - toggleDrawer() { - this.drawerOpen = !this.drawerOpen; + handleReaderSettingsUpdate(res: ReaderSettingUpdate) { + console.log('Handling ', res.setting, ' setting update to ', res.object); + switch (res.setting) { + case "pageStyle": + this.applyPageStyles(res.object as PageStyle); + break; + case "clickToPaginate": + this.showPaginationOverlay(res.object as boolean); + break; + case "fullscreen": + this.applyFullscreen(); + break; + case "writingStyle": + this.applyWritingStyle(); + break; + case "layoutMode": + this.applyLayoutMode(res.object as BookPageLayoutMode); + break; + case "readingDirection": + // No extra functionality needs to be done + break; + case "immersiveMode": + this.applyImmersiveMode(res.object as boolean); + break; + case 'theme': + this.applyColorTheme(res.object as BookTheme); + return; + } + } - if (this.immersiveMode) { + toggleDrawer() { + const drawerIsOpen = this.epubMenuService.isDrawerOpen(); + if (drawerIsOpen) { + this.epubMenuService.closeAll(); + } else { + this.epubMenuService.openSettingsDrawer(this.chapterId, this.seriesId, this.readingProfile); + } + + if (this.immersiveMode()) { // NOTE: Shouldn't this check if drawer is open? this.actionBarVisible = false; } this.cdRef.markForCheck(); @@ -1439,14 +1512,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (element === null) return; - if(this.layoutMode === BookPageLayoutMode.Default && this.writingStyle === WritingStyle.Vertical ) { + if(this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical ) { const windowWidth = window.innerWidth || document.documentElement.clientWidth; - const scrollLeft = element.getBoundingClientRect().left + window.pageXOffset - (windowWidth - element.getBoundingClientRect().width); + const scrollLeft = element.getBoundingClientRect().left + window.scrollX - (windowWidth - element.getBoundingClientRect().width); setTimeout(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'), 10); } - else if ((this.layoutMode === BookPageLayoutMode.Default) && (this.writingStyle === WritingStyle.Horizontal)) { - const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; - // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point + else if ((this.layoutMode() === BookPageLayoutMode.Default) && (this.writingStyle() === WritingStyle.Horizontal)) { + const fromTopOffset = element.getBoundingClientRect().top + window.scrollY + TOP_OFFSET; + // We need to use a delay as webkit browsers (aka Apple devices) don't always have the document rendered by this point setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); } else { setTimeout(() => (element as Element).scrollIntoView({'block': 'start', 'inline': 'start'})); @@ -1468,11 +1541,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.incognitoMode = false; const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); - this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); + this.toastr.info(translate('toasts.incognito-off')); this.saveProgress(); } - toggleFullscreen() { + applyFullscreen() { this.isFullscreen = this.readerService.checkFullscreenMode(); if (this.isFullscreen) { this.readerService.toggleFullscreen(this.reader.nativeElement, () => { @@ -1486,17 +1559,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); // HACK: This is a bug with how browsers change the background color for fullscreen mode this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); - if (!this.darkMode) { + if (!this.darkMode()) { this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); } }); } } - updateWritingStyle(writingStyle: WritingStyle) { - this.writingStyle = writingStyle; + applyWritingStyle() { setTimeout(() => this.updateImageSizes()); - if (this.layoutMode !== BookPageLayoutMode.Default) { + if (this.layoutMode() !== BookPageLayoutMode.Default) { const lastSelector = this.lastSeenScrollPartPath; setTimeout(() => { this.scrollTo(lastSelector); @@ -1512,10 +1584,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); } - updateLayoutMode(mode: BookPageLayoutMode) { - const layoutModeChanged = mode !== this.layoutMode; - this.layoutMode = mode; - this.cdRef.markForCheck(); + applyLayoutMode(mode: BookPageLayoutMode) { + //const layoutModeChanged = mode !== this.layoutMode(); // TODO: This functionality wont work on the new signal-based logic this.clearTimeout(this.updateImageSizeTimeout); this.updateImageSizeTimeout = setTimeout( () => { @@ -1525,10 +1595,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateSingleImagePageStyles() // Calculate if bottom actionbar is needed. On a timeout to get accurate heights - if (this.bookContentElemRef == null) { - setTimeout(() => this.updateLayoutMode(this.layoutMode), 10); - return; - } + // if (this.bookContentElemRef == null) { + // setTimeout(() => this.applyLayoutMode(this.layoutMode()), 10); + // return; + // } setTimeout(() => { this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; this.horizontalScrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientWidth > this.reader?.nativeElement?.clientWidth; @@ -1536,23 +1606,19 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); // When I switch layout, I might need to resume the progress point. - if (mode === BookPageLayoutMode.Default && layoutModeChanged) { - const lastSelector = this.lastSeenScrollPartPath; - setTimeout(() => this.scrollTo(lastSelector)); - } + // if (mode === BookPageLayoutMode.Default && layoutModeChanged) { + // const lastSelector = this.lastSeenScrollPartPath; + // setTimeout(() => this.scrollTo(lastSelector)); + // } } - updateReadingDirection(readingDirection: ReadingDirection) { - this.readingDirection = readingDirection; - this.cdRef.markForCheck(); - } - updateImmersiveMode(immersiveMode: boolean) { - this.immersiveMode = immersiveMode; - if (this.immersiveMode && !this.drawerOpen) { + applyImmersiveMode(immersiveMode: boolean) { + if (immersiveMode && !this.epubMenuService.isDrawerOpen()) { this.actionBarVisible = false; + this.updateReadingSectionHeight(); } - this.updateReadingSectionHeight(); + this.cdRef.markForCheck(); } @@ -1561,9 +1627,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const elem = this.readingSectionElemRef; setTimeout(() => { if (renderer === undefined || elem === undefined) return; - if (this.immersiveMode) { - } else { - renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); + if (!this.immersiveMode()) { + renderer.setStyle(elem.nativeElement, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); } }); } @@ -1590,7 +1655,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageAnchors = {}; this.currentPageAnchor = ''; this.cdRef.markForCheck(); - const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0); + const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum()).map(item => item.part).filter(item => item.length > 0); if (ids.length > 0) { const elems = this.getPageMarkers(ids); elems.forEach(elem => { @@ -1601,7 +1666,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Settings Handlers showPaginationOverlay(clickToPaginate: boolean) { - this.clickToPaginate = clickToPaginate; + this.readerSettingsService.updateClickToPaginate(clickToPaginate); this.cdRef.markForCheck(); this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); @@ -1645,7 +1710,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return ''; } - if (this.readingDirection === ReadingDirection.LeftToRight) { + if (this.readingDirection() === ReadingDirection.LeftToRight) { return side === 'right' ? 'highlight' : 'highlight-2'; } return side === 'right' ? 'highlight-2' : 'highlight'; @@ -1671,7 +1736,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const targetElement = (event.target as Element); const mouseOffset = 5; - if (!this.immersiveMode) return; + if (!this.immersiveMode()) return; if (targetElement.getAttribute('onclick') !== null || targetElement.getAttribute('href') !== null || targetElement.getAttribute('role') !== null || targetElement.getAttribute('kavita-part') != null) { // Don't do anything, it's actionable return; @@ -1699,4 +1764,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.isLineOverlayOpen = isOpen; this.cdRef.markForCheck(); } + + + viewToCDrawer() { + this.epubMenuService.openViewTocDrawer(this.chapterId, (res: LoadPageEvent | null) => { + if (res === null) return; + + this.setPageNum(res.pageNumber); + this.loadPage(res.part); + }); + } } diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html index 591972499..b48479a5c 100644 --- a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html @@ -1,32 +1,36 @@ - +
- @if (Pages.length === 0) { + @let bookmarks = ptocBookmarks(); + + @if(bookmarks.length >= ShowFilterAfterItems) { +
+
+
+ +
+ +
+
+
+
+ } + + @for(bookmark of bookmarks | filter: filterList; track bookmark.pageNumber + bookmark.title) { +
+ +
+ } @empty {
- {{t('no-data')}} + @if (formGroup.get('filter')?.value) { + {{t('no-match')}} + } @else { + {{t('no-data')}} + } +
} -
    - @for (page of Pages; track page) { -
  • - {{t('page', {value: page})}} -
      - @for(bookmark of bookmarks[page]; track bookmark) { -
    • - {{bookmark.title}} - -
    • - } -
    -
  • - } - -
diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts index d45dfb60e..fa118df01 100644 --- a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts @@ -1,19 +1,22 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, - Component, DestroyRef, EventEmitter, - Inject, + Component, + DestroyRef, + EventEmitter, inject, Input, + model, OnInit, Output } from '@angular/core'; -import {DOCUMENT} from '@angular/common'; import {ReaderService} from "../../../_services/reader.service"; import {PersonalToC} from "../../../_models/readers/personal-toc"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {TextBookmarkItemComponent} from "../text-bookmark-item/text-bookmark-item.component"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {FilterPipe} from "../../../_pipes/filter.pipe"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; export interface PersonalToCEvent { pageNum: number; @@ -21,31 +24,32 @@ export interface PersonalToCEvent { } @Component({ - selector: 'app-personal-table-of-contents', - imports: [NgbTooltip, TranslocoDirective], - templateUrl: './personal-table-of-contents.component.html', - styleUrls: ['./personal-table-of-contents.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-personal-table-of-contents', + imports: [TranslocoDirective, TextBookmarkItemComponent, FilterPipe, FormsModule, ReactiveFormsModule], + templateUrl: './personal-table-of-contents.component.html', + styleUrls: ['./personal-table-of-contents.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonalTableOfContentsComponent implements OnInit { + private readonly readerService = inject(ReaderService); + private readonly destroyRef = inject(DestroyRef); + private readonly confirmService = inject(ConfirmService); + + protected readonly ShowFilterAfterItems = 10; + @Input({required: true}) chapterId!: number; @Input({required: true}) pageNum: number = 0; @Input({required: true}) tocRefresh!: EventEmitter; @Output() loadChapter: EventEmitter = new EventEmitter(); - private readonly readerService = inject(ReaderService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly destroyRef = inject(DestroyRef); - bookmarks: {[key: number]: Array} = []; - get Pages() { - return Object.keys(this.bookmarks).map(p => parseInt(p, 10)); - } - - constructor(@Inject(DOCUMENT) private document: Document) {} + ptocBookmarks = model([]); + formGroup = new FormGroup({ + filter: new FormControl('', []) + }); ngOnInit() { this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { @@ -57,13 +61,7 @@ export class PersonalTableOfContentsComponent implements OnInit { load() { this.readerService.getPersonalToC(this.chapterId).subscribe(res => { - res.forEach(t => { - if (!this.bookmarks.hasOwnProperty(t.pageNumber)) { - this.bookmarks[t.pageNumber] = []; - } - this.bookmarks[t.pageNumber].push(t); - }) - this.cdRef.markForCheck(); + this.ptocBookmarks.set(res); }); } @@ -71,15 +69,18 @@ export class PersonalTableOfContentsComponent implements OnInit { this.loadChapter.emit({pageNum, scrollPart}); } - removeBookmark(bookmark: PersonalToC) { - this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => { - this.bookmarks[bookmark.pageNumber] = this.bookmarks[bookmark.pageNumber].filter(t => t.title != bookmark.title); + async removeBookmark(bookmark: PersonalToC) { - if (this.bookmarks[bookmark.pageNumber].length === 0) { - delete this.bookmarks[bookmark.pageNumber]; - } - this.cdRef.markForCheck(); + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-bookmark'))) return; + + this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => { + this.ptocBookmarks.set(this.ptocBookmarks().filter(t => t.title !== bookmark.title)); }); } + filterList = (listItem: PersonalToC) => { + const query = (this.formGroup.get('filter')?.value || '').toLowerCase(); + return listItem.title.toLowerCase().indexOf(query) >= 0 || listItem.pageNumber.toString().indexOf(query) >= 0 || (listItem.chapterTitle ?? '').toLowerCase().indexOf(query) >= 0; + } + } diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index a4bc4cdfa..16df84edf 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -1,6 +1,5 @@ @if (readingProfile !== null) { -
@@ -17,7 +16,9 @@
@@ -25,7 +26,9 @@ - +
@@ -34,7 +37,9 @@ {{t('line-spacing-min-label')}} - + {{t('line-spacing-max-label')}}
@@ -70,18 +75,18 @@
-
{{t('writing-style-tooltip')}} -
@@ -116,8 +121,10 @@
@@ -156,27 +163,28 @@
- - - + }
+ @let currentRP = currentReadingProfile();
diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index 52c067a16..641d6c02b 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -1,32 +1,16 @@ -import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - EventEmitter, - inject, - Inject, - Input, - OnInit, - Output -} from '@angular/core'; -import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {skip, take} from 'rxjs'; +import {NgClass, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import {FormGroup, ReactiveFormsModule} from '@angular/forms'; import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; import {BookTheme} from 'src/app/_models/preferences/book-theme'; import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; import {WritingStyle} from 'src/app/_models/preferences/writing-style'; import {ThemeProvider} from 'src/app/_models/preferences/site-theme'; -import {User} from 'src/app/_models/user'; -import {AccountService} from 'src/app/_services/account.service'; -import {ThemeService} from 'src/app/_services/theme.service'; -import {BookService, FontFamily} from '../../_services/book.service'; +import {FontFamily} from '../../_services/book.service'; import {BookBlackTheme} from '../../_models/book-black-theme'; import {BookDarkTheme} from '../../_models/book-dark-theme'; import {BookWhiteTheme} from '../../_models/book-white-theme'; import {BookPaperTheme} from '../../_models/book-paper-theme'; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { NgbAccordionBody, NgbAccordionButton, @@ -36,11 +20,10 @@ import { NgbAccordionItem, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfileService} from "../../../_services/reading-profile.service"; import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; -import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; -import {ToastrService} from "ngx-toastr"; +import {EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service"; /** * Used for book reader. Do not use for other components @@ -96,371 +79,95 @@ export const bookColorThemes = [ }, ]; -const mobileBreakpointMarginOverride = 700; - @Component({ selector: 'app-reader-settings', templateUrl: './reader-settings.component.html', styleUrls: ['./reader-settings.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, - NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, + NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, NgClass, NgStyle, TitleCasePipe, TranslocoDirective] }) export class ReaderSettingsComponent implements OnInit { + + private readonly readerSettingsService = inject(EpubReaderSettingsService); + private readonly destroyRef = inject(DestroyRef); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly readingProfileService = inject(ReadingProfileService); + @Input({required:true}) seriesId!: number; @Input({required:true}) readingProfile!: ReadingProfile; - /** - * Outputs when clickToPaginate is changed - */ - @Output() clickToPaginateChanged: EventEmitter = new EventEmitter(); - /** - * Outputs when a style is updated and the reader needs to render it - */ - @Output() styleUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when a theme/dark mode is updated - */ - @Output() colorThemeUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when a layout mode is updated - */ - @Output() layoutModeUpdate: EventEmitter = new EventEmitter(); - /** - * Outputs when fullscreen is toggled - */ - @Output() fullscreen: EventEmitter = new EventEmitter(); - /** - * Outputs when reading direction is changed - */ - @Output() readingDirection: EventEmitter = new EventEmitter(); - /** - * Outputs when reading mode is changed - */ - @Output() bookReaderWritingStyle: EventEmitter = new EventEmitter(); - /** - * Outputs when immersive mode is changed - */ - @Output() immersiveMode: EventEmitter = new EventEmitter(); - user!: User; /** * List of all font families user can select from */ fontOptions: Array = []; fontFamilies: Array = []; - /** - * Internal property used to capture all the different css properties to render on all elements - */ - pageStyles!: PageStyle; - - readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight; - - writingStyleModel: WritingStyle = WritingStyle.Horizontal; - - - activeTheme: BookTheme | undefined; - - isFullscreen: boolean = false; - settingsForm: FormGroup = new FormGroup({}); - - /** - * The reading profile itself, unless readingProfile is implicit - */ - parentReadingProfile: ReadingProfile | null = null; - /** * System provided themes */ - themes: Array = bookColorThemes; - private readonly destroyRef = inject(DestroyRef); + themes: Array = this.readerSettingsService.getThemes(); + + protected readonly pageStyles = this.readerSettingsService.pageStyles; + protected readonly readingDirectionModel = this.readerSettingsService.readingDirection; + protected readonly writingStyleModel = this.readerSettingsService.writingStyle; + protected readonly activeTheme = this.readerSettingsService.activeTheme; + protected readonly layoutMode = this.readerSettingsService.layoutMode; + protected readonly immersiveMode = this.readerSettingsService.immersiveMode; + protected readonly clickToPaginate = this.readerSettingsService.clickToPaginate; + protected readonly isFullscreen = this.readerSettingsService.isFullscreen; + protected readonly canPromoteProfile = this.readerSettingsService.canPromoteProfile; + protected readonly hasParentProfile = this.readerSettingsService.hasParentProfile; + protected readonly parentReadingProfile = this.readerSettingsService.parentReadingProfile; + protected readonly currentReadingProfile = this.readerSettingsService.currentReadingProfile; - get BookPageLayoutMode(): typeof BookPageLayoutMode { - return BookPageLayoutMode; - } - - get ReadingDirection() { - return ReadingDirection; - } - - get WritingStyle() { - return WritingStyle; - } - - constructor(private bookService: BookService, private accountService: AccountService, - @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService, - private toastr: ToastrService) {} - - ngOnInit(): void { - if (this.readingProfile.kind === ReadingProfileKind.Implicit) { - this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => { - this.parentReadingProfile = parent; - this.cdRef.markForCheck(); - }) - } else { - this.parentReadingProfile = this.readingProfile; - this.cdRef.markForCheck(); + async ngOnInit() { + // Initialize the service if not already done + if (!this.readerSettingsService.getCurrentReadingProfile()) { + await this.readerSettingsService.initialize(this.seriesId, this.readingProfile); } - this.fontFamilies = this.bookService.getFontFamilies(); + this.settingsForm = this.readerSettingsService.getSettingsForm(); + this.fontFamilies = this.readerSettingsService.getFontFamilies(); this.fontOptions = this.fontFamilies.map(f => f.title); - - - this.cdRef.markForCheck(); - - this.setupSettings(); - - this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false); - this.cdRef.markForCheck(); - - // Emit first time so book reader gets the setting - this.readingDirection.emit(this.readingDirectionModel); - this.bookReaderWritingStyle.emit(this.writingStyleModel); - this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate); - this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode); - this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode); - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.user = user; - } - - // User needs to be loaded before we call this - this.resetSettings(); - }); - } - - setupSettings() { - if (!this.readingProfile) return; - - if (this.readingProfile.bookReaderFontFamily === undefined) { - this.readingProfile.bookReaderFontFamily = 'default'; - } - if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) { - this.readingProfile.bookReaderFontSize = 100; - } - if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) { - this.readingProfile.bookReaderLineSpacing = 100; - } - if (this.readingProfile.bookReaderMargin === undefined) { - this.readingProfile.bookReaderMargin = 0; - } - if (this.readingProfile.bookReaderReadingDirection === undefined) { - this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight; - } - if (this.readingProfile.bookReaderWritingStyle === undefined) { - this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal; - } - this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection; - this.writingStyleModel = this.readingProfile.bookReaderWritingStyle; - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, [])); - this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => { - const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; - if (familyName === 'default') { - this.pageStyles['font-family'] = 'inherit'; - } else { - this.pageStyles['font-family'] = "'" + familyName + "'"; - } - - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, [])); - this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['font-size'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, [])); - this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.clickToPaginateChanged.emit(value); - }); - - this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, [])); - this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['line-height'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, [])); - this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['margin-left'] = value + 'vw'; - this.pageStyles['margin-right'] = value + 'vw'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, [])); - this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => { - this.layoutModeUpdate.emit(layoutMode); - }); - - this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, [])); - this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => { - if (immersiveMode) { - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); - } - this.immersiveMode.emit(immersiveMode); - }); - - // Update implicit reading profile while changing settings - this.settingsForm.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot - takeUntilDestroyed(this.destroyRef), - tap(_ => this.updateImplicit()) - ).subscribe(); } resetSettings() { - if (!this.readingProfile) return; - - if (this.user) { - this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%'); - } else { - this.setPageStyles(); - } - - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily); - this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize); - this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing); - this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin); - this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection); - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate); - this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode); - this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode); - this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle); - - this.cdRef.detectChanges(); - this.styleUpdate.emit(this.pageStyles); - } - - updateImplicit() { - this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({ - next: newProfile => { - this.readingProfile = newProfile; - this.cdRef.markForCheck(); - }, - error: err => { - console.error(err); - } - }) - } - - /** - * Internal method to be used by resetSettings. Pass items in with quantifiers - */ - setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string, colorTheme?: string) { - const windowWidth = window.innerWidth - || this.document.documentElement.clientWidth - || this.document.body.clientWidth; - - - let defaultMargin = '15vw'; - if (windowWidth <= mobileBreakpointMarginOverride) { - defaultMargin = '5vw'; - } - this.pageStyles = { - 'font-family': fontFamily || this.pageStyles['font-family'] || 'default', - 'font-size': fontSize || this.pageStyles['font-size'] || '100%', - 'margin-left': margin || this.pageStyles['margin-left'] || defaultMargin, - 'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin, - 'line-height': lineHeight || this.pageStyles['line-height'] || '100%' - }; + this.readerSettingsService.resetSettings(); } setTheme(themeName: string, update: boolean = true) { - const theme = this.themes.find(t => t.name === themeName); - this.activeTheme = theme; - this.cdRef.markForCheck(); - this.colorThemeUpdate.emit(theme); - - if (update) { - this.updateImplicit(); - } + this.readerSettingsService.setTheme(themeName, update); } toggleReadingDirection() { - if (this.readingDirectionModel === ReadingDirection.LeftToRight) { - this.readingDirectionModel = ReadingDirection.RightToLeft; - } else { - this.readingDirectionModel = ReadingDirection.LeftToRight; - } - - this.cdRef.markForCheck(); - this.readingDirection.emit(this.readingDirectionModel); - this.updateImplicit(); + this.readerSettingsService.toggleReadingDirection(); } toggleWritingStyle() { - if (this.writingStyleModel === WritingStyle.Horizontal) { - this.writingStyleModel = WritingStyle.Vertical - } else { - this.writingStyleModel = WritingStyle.Horizontal - } - - this.cdRef.markForCheck(); - this.bookReaderWritingStyle.emit(this.writingStyleModel); - this.updateImplicit(); + this.readerSettingsService.toggleWritingStyle(); } toggleFullscreen() { - this.isFullscreen = !this.isFullscreen; + this.readerSettingsService.toggleFullscreen(); this.cdRef.markForCheck(); - this.fullscreen.emit(); } // menu only code updateParentPref() { - if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { - return; - } - - this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => { - this.readingProfile = newProfile; - this.toastr.success(translate('manga-reader.reading-profile-updated')); - this.cdRef.markForCheck(); - }); + this.readerSettingsService.updateParentProfile(); } createNewProfileFromImplicit() { - if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { - return; - } - - this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { - this.readingProfile = newProfile; - this.parentReadingProfile = newProfile; // profile is no longer implicit - this.cdRef.markForCheck(); - - this.toastr.success(translate("manga-reader.reading-profile-promoted")); - }); + this.readerSettingsService.createNewProfileFromImplicit(); } - private packReadingProfile(): ReadingProfile { - const modelSettings = this.settingsForm.getRawValue(); - const data = {...this.readingProfile!}; - data.bookReaderFontFamily = modelSettings.bookReaderFontFamily; - data.bookReaderFontSize = modelSettings.bookReaderFontSize - data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing; - data.bookReaderMargin = modelSettings.bookReaderMargin; - data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate; - data.bookReaderLayoutMode = modelSettings.layoutMode; - data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode; - - data.bookReaderReadingDirection = this.readingDirectionModel; - data.bookReaderWritingStyle = this.writingStyleModel; - if (this.activeTheme) { - data.bookReaderThemeName = this.activeTheme.name; - } - - return data; - } protected readonly ReadingProfileKind = ReadingProfileKind; + protected readonly WritingStyle = WritingStyle; + protected readonly ReadingDirection = ReadingDirection; + protected readonly BookPageLayoutMode = BookPageLayoutMode; } diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html new file mode 100644 index 000000000..3cff44c0f --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.html @@ -0,0 +1,29 @@ + + @let ptoc = bookmark(); + @if (ptoc) { + +
+ +
+
+ {{ptoc.title}} + + +
+
+ @if (ptoc.chapterTitle) { + Chapter "{{ptoc.chapterTitle}}" - + } + {{t('page', {value: ptoc.pageNumber})}} +
+
+ + + + +
+ } +
diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss new file mode 100644 index 000000000..c947fddfa --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.scss @@ -0,0 +1,7 @@ +.card:hover { + background-color: var(--elevation-layer7) +} +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts new file mode 100644 index 000000000..258262169 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/text-bookmark-item/text-bookmark-item.component.ts @@ -0,0 +1,36 @@ +import {Component, EventEmitter, input, Output} from '@angular/core'; +import {PersonalToC} from "../../../_models/readers/personal-toc"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-text-bookmark-item', + imports: [ + NgbTooltip, + TranslocoDirective + ], + templateUrl: './text-bookmark-item.component.html', + styleUrl: './text-bookmark-item.component.scss' +}) +export class TextBookmarkItemComponent { + bookmark = input.required(); + + @Output() loadBookmark = new EventEmitter(); + @Output() removeBookmark = new EventEmitter(); + + + remove(evt: Event) { + evt.stopPropagation(); + evt.preventDefault(); + + this.removeBookmark.emit(this.bookmark()); + } + + goTo(evt: Event) { + evt.stopPropagation(); + evt.preventDefault(); + + this.loadBookmark.emit(this.bookmark()); + } + +} diff --git a/UI/Web/src/app/book-reader/_models/annotation.ts b/UI/Web/src/app/book-reader/_models/annotation.ts new file mode 100644 index 000000000..917c9cfbb --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/annotation.ts @@ -0,0 +1,23 @@ +export enum HightlightColor { + Blue = 1, + Green = 2, +} + +export interface Annotation { + id: number; + xpath: string; + endingXPath: string | null; + selectedText: string | null; + comment: string; + hightlightColor: HightlightColor; + containsSpoiler: boolean; + pageNumber: number; + + + chapterId: number; + + ownerUserId: number; + ownerUsername: string; + createdUtc: string; + lastModifiedUtc: string; +} diff --git a/UI/Web/src/app/book-reader/_models/book-info.ts b/UI/Web/src/app/book-reader/_models/book-info.ts index 4816bd324..b0649123b 100644 --- a/UI/Web/src/app/book-reader/_models/book-info.ts +++ b/UI/Web/src/app/book-reader/_models/book-info.ts @@ -1,9 +1,9 @@ -import { MangaFormat } from "src/app/_models/manga-format"; +import {MangaFormat} from "src/app/_models/manga-format"; export interface BookInfo { - bookTitle: string; - seriesFormat: MangaFormat; - seriesId: number; - libraryId: number; - volumeId: number; -} \ No newline at end of file + bookTitle: string; + seriesFormat: MangaFormat; + seriesId: number; + libraryId: number; + volumeId: number; +} diff --git a/UI/Web/src/app/book-reader/_models/create-annotation-request.ts b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts new file mode 100644 index 000000000..ead6954cd --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts @@ -0,0 +1,15 @@ +import {HightlightColor} from "./annotation"; + +export interface CreateAnnotationRequest { + libraryId: number; + volumeId: number; + chapterId: number; + xpath: string; + endingXPath: string | null; + selectedText: string | null; + comment: string | null; + hightlightColor: HightlightColor; + highlightCount: number; + containsSpoiler: boolean; + pageNumber: number; +} diff --git a/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts b/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts new file mode 100644 index 000000000..b598f36ac --- /dev/null +++ b/UI/Web/src/app/book-reader/_pipes/column-layout-class.pipe.ts @@ -0,0 +1,20 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode"; + +@Pipe({ + name: 'columnLayoutClass' +}) +export class ColumnLayoutClassPipe implements PipeTransform { + + transform(value: BookPageLayoutMode): string { + switch (value) { + case BookPageLayoutMode.Default: + return ''; + case BookPageLayoutMode.Column1: + return 'column-layout-1'; + case BookPageLayoutMode.Column2: + return 'column-layout-2'; + } + } + +} diff --git a/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts b/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts new file mode 100644 index 000000000..b84d06feb --- /dev/null +++ b/UI/Web/src/app/book-reader/_pipes/writing-style-class.pipe.ts @@ -0,0 +1,18 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {WritingStyle} from "../../_models/preferences/writing-style"; + +@Pipe({ + name: 'writingStyleClass' +}) +export class WritingStyleClassPipe implements PipeTransform { + + transform(value: WritingStyle): string { + switch (value) { + case WritingStyle.Horizontal: + return ''; + case WritingStyle.Vertical: + return 'writing-style-vertical'; + } + } + +} diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index d98f09f38..5dd433482 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -1,9 +1,9 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { TextResonse } from 'src/app/_types/text-response'; -import { environment } from 'src/environments/environment'; -import { BookChapterItem } from '../_models/book-chapter-item'; -import { BookInfo } from '../_models/book-info'; +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {TextResonse} from 'src/app/_types/text-response'; +import {environment} from 'src/environments/environment'; +import {BookChapterItem} from '../_models/book-chapter-item'; +import {BookInfo} from '../_models/book-info'; export interface FontFamily { /** @@ -28,7 +28,8 @@ export class BookService { getFontFamilies(): Array { return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, - {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; + {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, + {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; } getBookChapters(chapterId: number) { @@ -39,8 +40,8 @@ export class BookService { return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse); } - getBookInfo(chapterId: number) { - return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-info'); + getBookInfo(chapterId: number, includeWordCounts: boolean = false) { + return this.http.get(this.baseUrl + `book/${chapterId}/book-info?includeWordCounts=${includeWordCounts}`); } getBookPageUrl(chapterId: number, page: number) { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 33bde5e0e..c8252f249 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -800,12 +800,40 @@ "book-line-overlay": { "copy": "Copy", "bookmark": "Bookmark", + "annotate": "Annotate", "close": "{{common.close}}", "required-field": "{{common.required-field}}", "bookmark-label": "Bookmark Name", "save": "{{common.save}}" }, + "view-annotation-drawer": { + "title": "Annotations", + "close": "{{common.close}}" + }, + + "view-bookmark-drawer": { + "title": "Image Bookmarks", + "close": "{{common.close}}" + }, + + "view-toc-drawer": { + "title": "Table of Contents", + "close": "{{common.close}}", + "toc-header": "Book", + "personal-header": "Personal" + }, + + "epub-setting-drawer": { + "title": "Book Settings", + "close": "{{common.close}}" + }, + + "create-annotation-drawer": { + "title": "Create/Edit an Annotation", + "close": "{{common.close}}" + }, + "book-reader": { "title": "Book Settings", "page-label": "Page", @@ -837,13 +865,18 @@ "go-to-page-prompt": "There are {{totalPages}} pages. What page do you want to go to?", "go-to-section": "Go to section", - "go-to-section-prompt": "There are {{totalSections}} sections. What section do you want to go to?" + "go-to-section-prompt": "There are {{totalSections}} sections. What section do you want to go to?", + + "page-num-label": "Page {{page}}", + "completion-label": "{{percent}} complete" }, "personal-table-of-contents": { "no-data": "Nothing Bookmarked yet", "page": "Page {{value}}", - "delete": "Delete {{bookmarkName}}" + "delete": "Delete {{bookmarkName}}", + "no-match": "No Bookmarks match filter", + "filter-label": "{{common.filter}}" }, "confirm-email": { @@ -2748,13 +2781,16 @@ "scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.", "series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}", "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}", - "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes." + "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes.", + "confirm-delete-bookmark": "Are you sure you want to delete this Bookmark?" }, "read-time-pipe": { "less-than-hour": "<1 Hour", "hour": "Hour", - "hours": "Hours" + "hours": "Hours", + "hour-left": "Hour left", + "hours-left": "Hours left" }, "metadata-setting-field-pipe": {