From a0a6da9c60e86a68e94079f63ecbb1e43121c580 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Fri, 21 Jul 2023 17:29:35 -0500 Subject: [PATCH] Personal Table of Contents (#2148) * Fixed a bad default setting for token key * Changed the payment link to support Google Pay * Fixed duplicate events occurring on newly added series from a scan. Fixed the version update code from not firing and made it check every 4-6 hours (random per user per restart) * Check for new releases on startup as well. Added Personal Table of Contents (called Bookmarks on epub and pdf reader). The idea is that sometimes you want to bookmark certain parts of pages to get back to quickly later. This mechanism will allow you to do that without having to edit the underlying ToC. * Added a button to update modal to show how to update for those unaware. * Darkened the link text within tables to be more visible. * Update link for how to update now is dynamic for docker users * Refactored to send proper star/end dates for scrobble read events for upcoming changes in the API. Added GoogleBooks Rating UI code if I go forward with API changes. * When Scrobbling, send when the first and last progress for the series was. Added OpenLibrary icon for upcoming enhancements for Kavita+. Changed the Update checker to execute at start. * Fixed backups not saving favicons in the correct place * Refactored the layout code for Personal ToC * More bugfixes around toc * Box alignment * Fixed up closing the overlay when bookmark mode is active * Fixed up closing the overlay when bookmark mode is active --------- Co-authored-by: Robbie Davis --- API/Controllers/BookController.cs | 1 - API/Controllers/ReaderController.cs | 55 + API/DTOs/Reader/CreatePersonalToCDto.cs | 12 + API/DTOs/Reader/PersonalToCDto.cs | 9 + API/DTOs/Scrobbling/ScrobbleDto.cs | 8 + API/DTOs/Scrobbling/ScrobbleEventDto.cs | 2 +- API/Data/DataContext.cs | 1 + .../20230719173458_PersonalToC.Designer.cs | 2266 +++++++++++++++++ .../Migrations/20230719173458_PersonalToC.cs | 79 + .../Migrations/DataContextModelSnapshot.cs | 152 +- .../Repositories/AppUserProgressRepository.cs | 20 +- .../UserTableOfContentRepository.cs | 64 + API/Data/UnitOfWork.cs | 2 + API/Entities/AppUser.cs | 4 +- API/Entities/AppUserTableOfContent.cs | 49 + API/Entities/Scrobble/ScrobbleEvent.cs | 1 + API/Helpers/AutoMapperProfiles.cs | 6 + API/Helpers/Builders/PlusSeriesDtoBuilder.cs | 36 + API/Services/BookService.cs | 3 +- .../StartupTasksHostedService.cs | 1 - API/Services/Plus/RatingService.cs | 15 +- API/Services/Plus/RecommendationService.cs | 16 +- API/Services/Plus/ScrobblingService.cs | 89 +- API/Services/ReviewService.cs | 15 +- API/Services/TaskScheduler.cs | 7 +- API/Services/Tasks/BackupService.cs | 2 +- API/Services/Tasks/VersionUpdaterService.cs | 8 +- API/config/appsettings.json | 2 +- .../src/app/_models/readers/personal-toc.ts | 8 + UI/Web/src/app/_services/reader.service.ts | 54 +- .../src/app/_services/scrobbling.service.ts | 21 +- .../manage-email-settings.component.html | 2 +- .../manage-system.component.html | 45 +- .../book-line-overlay.component.html | 31 + .../book-line-overlay.component.scss | 9 + .../book-line-overlay.component.ts | 128 + .../book-reader/book-reader.component.html | 30 +- .../book-reader/book-reader.component.ts | 70 +- .../personal-table-of-contents.component.html | 22 + .../personal-table-of-contents.component.scss | 15 + .../personal-table-of-contents.component.ts | 85 + .../table-of-contents.component.ts | 14 +- .../app/book-reader/_services/book.service.ts | 8 +- .../_components/dashboard.component.ts | 1 + .../events-widget/events-widget.component.ts | 20 +- UI/Web/src/app/pipe/provider-image.pipe.ts | 2 + .../update-notification-modal.component.html | 11 +- .../update-notification-modal.component.ts | 13 +- .../images/ExternalServices/GoogleBooks.png | Bin 0 -> 523 bytes .../images/ExternalServices/OpenLibrary.png | Bin 0 -> 1071 bytes UI/Web/src/environments/environment.prod.ts | 2 +- UI/Web/src/theme/components/_anchors.scss | 4 + openapi.json | 262 +- 53 files changed, 3538 insertions(+), 244 deletions(-) create mode 100644 API/DTOs/Reader/CreatePersonalToCDto.cs create mode 100644 API/DTOs/Reader/PersonalToCDto.cs create mode 100644 API/Data/Migrations/20230719173458_PersonalToC.Designer.cs create mode 100644 API/Data/Migrations/20230719173458_PersonalToC.cs create mode 100644 API/Data/Repositories/UserTableOfContentRepository.cs create mode 100644 API/Entities/AppUserTableOfContent.cs create mode 100644 API/Helpers/Builders/PlusSeriesDtoBuilder.cs create mode 100644 UI/Web/src/app/_models/readers/personal-toc.ts create mode 100644 UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html create mode 100644 UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.scss create mode 100644 UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts create mode 100644 UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html create mode 100644 UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.scss create mode 100644 UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts create mode 100644 UI/Web/src/assets/images/ExternalServices/GoogleBooks.png create mode 100644 UI/Web/src/assets/images/ExternalServices/OpenLibrary.png diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 207fcafba..f64bc2d55 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -158,5 +158,4 @@ public class BookController : BaseApiController return BadRequest(ex.Message); } } - } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 356592dfd..ca9d736ad 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -792,4 +792,59 @@ public class ReaderController : BaseApiController return _readerService.GetTimeEstimate(0, pagesLeft, false); } + /// + /// Returns the user's personal table of contents for the given chapter + /// + /// + /// + [HttpGet("ptoc")] + public ActionResult> GetPersonalToC(int chapterId) + { + return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId)); + } + + [HttpDelete("ptoc")] + public async Task DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title) + { + if (string.IsNullOrWhiteSpace(title)) return BadRequest("Name cannot be empty"); + if (pageNum < 0) return BadRequest("Must be valid page number"); + var toc = await _unitOfWork.UserTableOfContentRepository.Get(User.GetUserId(), chapterId, pageNum, title); + if (toc == null) return Ok(); + _unitOfWork.UserTableOfContentRepository.Remove(toc); + await _unitOfWork.CommitAsync(); + return Ok(); + } + + /// + /// Create a new personal table of content entry for a given chapter + /// + /// The title and page number must be unique to that book + /// + /// + [HttpPost("create-ptoc")] + public async Task CreatePersonalToC(CreatePersonalToCDto dto) + { + // Validate there isn't already an existing page title combo? + if (string.IsNullOrWhiteSpace(dto.Title)) return BadRequest("Name cannot be empty"); + if (dto.PageNumber < 0) return BadRequest("Must be valid page number"); + var userId = User.GetUserId(); + if (await _unitOfWork.UserTableOfContentRepository.IsUnique(userId, dto.ChapterId, dto.PageNumber, + dto.Title.Trim())) + { + return BadRequest("Duplicate ToC entry already exists"); + } + + _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() + { + Title = dto.Title.Trim(), + ChapterId = dto.ChapterId, + PageNumber = dto.PageNumber, + SeriesId = dto.SeriesId, + LibraryId = dto.LibraryId, + BookScrollId = dto.BookScrollId, + AppUserId = userId + }); + await _unitOfWork.CommitAsync(); + return Ok(); + } } diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs new file mode 100644 index 000000000..25526b490 --- /dev/null +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -0,0 +1,12 @@ +namespace API.DTOs.Reader; + +public class CreatePersonalToCDto +{ + public required int ChapterId { get; set; } + public required int VolumeId { get; set; } + public required int SeriesId { get; set; } + public required int LibraryId { get; set; } + public required int PageNumber { get; set; } + public required string Title { get; set; } + public string? BookScrollId { get; set; } +} diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs new file mode 100644 index 000000000..6763a157a --- /dev/null +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Reader; + +public class PersonalToCDto +{ + public required int ChapterId { get; set; } + public required int PageNumber { get; set; } + public required string Title { get; set; } + public string? BookScrollId { get; set; } +} diff --git a/API/DTOs/Scrobbling/ScrobbleDto.cs b/API/DTOs/Scrobbling/ScrobbleDto.cs index 5d2dc3ebc..e58de0576 100644 --- a/API/DTOs/Scrobbling/ScrobbleDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleDto.cs @@ -66,8 +66,16 @@ public class ScrobbleDto /// public DateTime? StartedReadingDateUtc { get; set; } /// + /// The latest date the series was read. Will be null for non ReadingProgress events + /// + public DateTime? LatestReadingDateUtc { get; set; } + /// /// The date that the series was scrobbled. Will be null for non ReadingProgress events /// public DateTime? ScrobbleDateUtc { get; set; } + /// + /// Optional but can help with matching + /// + public string? Isbn { get; set; } } diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index 906d66bd4..3ed0cd569 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -10,9 +10,9 @@ public class ScrobbleEventDto public bool IsProcessed { get; set; } public int? VolumeNumber { get; set; } public int? ChapterNumber { get; set; } - public DateTime? ProcessDateUtc { get; set; } public DateTime LastModified { get; set; } public DateTime Created { get; set; } public float? Rating { get; set; } public ScrobbleEventType ScrobbleEventType { get; set; } + } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index e4dc6b746..5faec1cde 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -53,6 +53,7 @@ public sealed class DataContext : IdentityDbContext ScrobbleError { get; set; } = null!; public DbSet ScrobbleHold { get; set; } = null!; public DbSet AppUserOnDeckRemoval { get; set; } = null!; + public DbSet AppUserTableOfContent { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20230719173458_PersonalToC.Designer.cs b/API/Data/Migrations/20230719173458_PersonalToC.Designer.cs new file mode 100644 index 000000000..50e9ffd61 --- /dev/null +++ b/API/Data/Migrations/20230719173458_PersonalToC.Designer.cs @@ -0,0 +1,2266 @@ +// +using System; +using API.Data; +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("20230719173458_PersonalToC")] + partial class PersonalToC + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.8"); + + 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("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("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.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.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("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("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .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.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("Rating") + .HasColumnType("INTEGER"); + + 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.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.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("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("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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("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("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + 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("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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.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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + 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("Promoted") + .HasColumnType("INTEGER"); + + 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("Format") + .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("INTEGER"); + + 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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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("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("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .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("Provider") + .HasColumnType("INTEGER"); + + 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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + 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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .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.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.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.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.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .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.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.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.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + 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("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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/20230719173458_PersonalToC.cs b/API/Data/Migrations/20230719173458_PersonalToC.cs new file mode 100644 index 000000000..c3eb9e025 --- /dev/null +++ b/API/Data/Migrations/20230719173458_PersonalToC.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonalToC : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserTableOfContent", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PageNumber = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false), + LibraryId = table.Column(type: "INTEGER", nullable: false), + BookScrollId = table.Column(type: "TEXT", nullable: true), + 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), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserTableOfContent", x => x.Id); + table.ForeignKey( + name: "FK_AppUserTableOfContent_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserTableOfContent_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserTableOfContent_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserTableOfContent_AppUserId", + table: "AppUserTableOfContent", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserTableOfContent_ChapterId", + table: "AppUserTableOfContent", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserTableOfContent_SeriesId", + table: "AppUserTableOfContent", + column: "SeriesId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserTableOfContent"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index cc8d2660f..c2a9ba150 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -180,7 +180,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserBookmark"); + b.ToTable("AppUserBookmark", (string)null); }); modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => @@ -201,7 +201,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserOnDeckRemoval"); + b.ToTable("AppUserOnDeckRemoval", (string)null); }); modelBuilder.Entity("API.Entities.AppUserPreferences", b => @@ -309,7 +309,7 @@ namespace API.Data.Migrations b.HasIndex("ThemeId"); - b.ToTable("AppUserPreferences"); + b.ToTable("AppUserPreferences", (string)null); }); modelBuilder.Entity("API.Entities.AppUserProgress", b => @@ -359,7 +359,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserProgresses"); + b.ToTable("AppUserProgresses", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRating", b => @@ -389,7 +389,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserRating"); + b.ToTable("AppUserRating", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRole", b => @@ -407,6 +407,59 @@ namespace API.Data.Migrations b.ToTable("AspNetUserRoles", (string)null); }); + 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("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("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", (string)null); + }); + modelBuilder.Entity("API.Entities.Chapter", b => { b.Property("Id") @@ -514,7 +567,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("Chapter"); + b.ToTable("Chapter", (string)null); }); modelBuilder.Entity("API.Entities.CollectionTag", b => @@ -549,7 +602,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "Promoted") .IsUnique(); - b.ToTable("CollectionTag"); + b.ToTable("CollectionTag", (string)null); }); modelBuilder.Entity("API.Entities.Device", b => @@ -595,7 +648,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("Device"); + b.ToTable("Device", (string)null); }); modelBuilder.Entity("API.Entities.FolderPath", b => @@ -617,7 +670,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("FolderPath"); + b.ToTable("FolderPath", (string)null); }); modelBuilder.Entity("API.Entities.Genre", b => @@ -637,7 +690,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Genre"); + b.ToTable("Genre", (string)null); }); modelBuilder.Entity("API.Entities.Library", b => @@ -695,7 +748,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Library"); + b.ToTable("Library", (string)null); }); modelBuilder.Entity("API.Entities.MangaFile", b => @@ -744,7 +797,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("MangaFile"); + b.ToTable("MangaFile", (string)null); }); modelBuilder.Entity("API.Entities.MediaError", b => @@ -779,7 +832,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("MediaError"); + b.ToTable("MediaError", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => @@ -880,7 +933,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "SeriesId") .IsUnique(); - b.ToTable("SeriesMetadata"); + b.ToTable("SeriesMetadata", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => @@ -904,7 +957,7 @@ namespace API.Data.Migrations b.HasIndex("TargetSeriesId"); - b.ToTable("SeriesRelation"); + b.ToTable("SeriesRelation", (string)null); }); modelBuilder.Entity("API.Entities.Person", b => @@ -924,7 +977,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Person"); + b.ToTable("Person", (string)null); }); modelBuilder.Entity("API.Entities.ReadingList", b => @@ -987,7 +1040,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("ReadingList"); + b.ToTable("ReadingList", (string)null); }); modelBuilder.Entity("API.Entities.ReadingListItem", b => @@ -1021,7 +1074,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("ReadingListItem"); + b.ToTable("ReadingListItem", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => @@ -1066,7 +1119,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleError"); + b.ToTable("ScrobbleError", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => @@ -1137,7 +1190,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleEvent"); + b.ToTable("ScrobbleEvent", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => @@ -1170,7 +1223,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleHold"); + b.ToTable("ScrobbleHold", (string)null); }); modelBuilder.Entity("API.Entities.Series", b => @@ -1266,7 +1319,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("Series"); + b.ToTable("Series", (string)null); }); modelBuilder.Entity("API.Entities.ServerSetting", b => @@ -1283,7 +1336,7 @@ namespace API.Data.Migrations b.HasKey("Key"); - b.ToTable("ServerSetting"); + b.ToTable("ServerSetting", (string)null); }); modelBuilder.Entity("API.Entities.ServerStatistics", b => @@ -1321,7 +1374,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("ServerStatistics"); + b.ToTable("ServerStatistics", (string)null); }); modelBuilder.Entity("API.Entities.SiteTheme", b => @@ -1359,7 +1412,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("SiteTheme"); + b.ToTable("SiteTheme", (string)null); }); modelBuilder.Entity("API.Entities.Tag", b => @@ -1379,7 +1432,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Tag"); + b.ToTable("Tag", (string)null); }); modelBuilder.Entity("API.Entities.Volume", b => @@ -1431,7 +1484,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("Volume"); + b.ToTable("Volume", (string)null); }); modelBuilder.Entity("AppUserLibrary", b => @@ -1446,7 +1499,7 @@ namespace API.Data.Migrations b.HasIndex("LibrariesId"); - b.ToTable("AppUserLibrary"); + b.ToTable("AppUserLibrary", (string)null); }); modelBuilder.Entity("ChapterGenre", b => @@ -1461,7 +1514,7 @@ namespace API.Data.Migrations b.HasIndex("GenresId"); - b.ToTable("ChapterGenre"); + b.ToTable("ChapterGenre", (string)null); }); modelBuilder.Entity("ChapterPerson", b => @@ -1476,7 +1529,7 @@ namespace API.Data.Migrations b.HasIndex("PeopleId"); - b.ToTable("ChapterPerson"); + b.ToTable("ChapterPerson", (string)null); }); modelBuilder.Entity("ChapterTag", b => @@ -1491,7 +1544,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("ChapterTag"); + b.ToTable("ChapterTag", (string)null); }); modelBuilder.Entity("CollectionTagSeriesMetadata", b => @@ -1506,7 +1559,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("CollectionTagSeriesMetadata"); + b.ToTable("CollectionTagSeriesMetadata", (string)null); }); modelBuilder.Entity("GenreSeriesMetadata", b => @@ -1521,7 +1574,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("GenreSeriesMetadata"); + b.ToTable("GenreSeriesMetadata", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -1620,7 +1673,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("PersonSeriesMetadata"); + b.ToTable("PersonSeriesMetadata", (string)null); }); modelBuilder.Entity("SeriesMetadataTag", b => @@ -1635,7 +1688,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("SeriesMetadataTag"); + b.ToTable("SeriesMetadataTag", (string)null); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => @@ -1746,6 +1799,33 @@ namespace API.Data.Migrations b.Navigation("User"); }); + 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.Chapter", b => { b.HasOne("API.Entities.Volume", "Volume") @@ -2130,6 +2210,8 @@ namespace API.Data.Migrations b.Navigation("ScrobbleHolds"); + b.Navigation("TableOfContents"); + b.Navigation("UserPreferences"); b.Navigation("UserRoles"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 28e7ed91e..070cc1cf5 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -31,6 +31,8 @@ public interface IAppUserProgressRepository Task AnyUserProgressForSeriesAsync(int seriesId, int userId); Task GetHighestFullyReadChapterForSeries(int seriesId, int userId); Task GetHighestFullyReadVolumeForSeries(int seriesId, int userId); + Task GetLatestProgressForSeries(int seriesId, int userId); + Task GetFirstProgressForSeries(int seriesId, int userId); } #nullable disable public class AppUserProgressRepository : IAppUserProgressRepository @@ -179,7 +181,23 @@ public class AppUserProgressRepository : IAppUserProgressRepository return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max(); } - #nullable enable + public async Task GetLatestProgressForSeries(int seriesId, int userId) + { + var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId) + .Select(p => p.LastModifiedUtc) + .ToListAsync(); + return list.Count == 0 ? null : list.DefaultIfEmpty().Max(); + } + + public async Task GetFirstProgressForSeries(int seriesId, int userId) + { + var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId) + .Select(p => p.LastModifiedUtc) + .ToListAsync(); + return list.Count == 0 ? null : list.DefaultIfEmpty().Min(); + } + +#nullable enable public async Task GetUserProgressAsync(int chapterId, int userId) { return await _context.AppUserProgresses diff --git a/API/Data/Repositories/UserTableOfContentRepository.cs b/API/Data/Repositories/UserTableOfContentRepository.cs new file mode 100644 index 000000000..b640ec9a0 --- /dev/null +++ b/API/Data/Repositories/UserTableOfContentRepository.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Reader; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; +#nullable enable + +public interface IUserTableOfContentRepository +{ + void Attach(AppUserTableOfContent toc); + void Remove(AppUserTableOfContent toc); + Task IsUnique(int userId, int chapterId, int page, string title); + IEnumerable GetPersonalToC(int userId, int chapterId); + Task Get(int userId, int chapterId, int pageNum, string title); +} + +public class UserTableOfContentRepository : IUserTableOfContentRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public UserTableOfContentRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(AppUserTableOfContent toc) + { + _context.AppUserTableOfContent.Attach(toc); + } + + public void Remove(AppUserTableOfContent toc) + { + _context.AppUserTableOfContent.Remove(toc); + } + + public async Task IsUnique(int userId, int chapterId, int page, string title) + { + return await _context.AppUserTableOfContent.AnyAsync(t => + t.AppUserId == userId && t.PageNumber == page && t.Title == title && t.ChapterId == chapterId); + } + + public IEnumerable GetPersonalToC(int userId, int chapterId) + { + return _context.AppUserTableOfContent + .Where(t => t.AppUserId == userId && t.ChapterId == chapterId) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.PageNumber) + .AsEnumerable(); + } + + public async Task Get(int userId,int chapterId, int pageNum, string title) + { + return await _context.AppUserTableOfContent + .Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == pageNum && t.Title == title) + .FirstOrDefaultAsync(); + } +} diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 6d79f1922..8eb1f3a31 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -27,6 +27,7 @@ public interface IUnitOfWork IDeviceRepository DeviceRepository { get; } IMediaErrorRepository MediaErrorRepository { get; } IScrobbleRepository ScrobbleRepository { get; } + IUserTableOfContentRepository UserTableOfContentRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -66,6 +67,7 @@ public class UnitOfWork : IUnitOfWork public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper); public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper); + public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 58f7d7033..f50262ef0 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -37,9 +37,9 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public ICollection Devices { get; set; } = null!; /// - /// A list of Series the user doesn't want on deck + /// A list of Table of Contents for a given Chapter /// - //public ICollection OnDeckRemovals { get; set; } = null!; + public ICollection TableOfContents { get; set; } = null!; /// /// An API Key to interact with external services, like OPDS /// diff --git a/API/Entities/AppUserTableOfContent.cs b/API/Entities/AppUserTableOfContent.cs new file mode 100644 index 000000000..bc0f604bc --- /dev/null +++ b/API/Entities/AppUserTableOfContent.cs @@ -0,0 +1,49 @@ +using System; +using API.Entities.Interfaces; + +namespace API.Entities; + +/// +/// A personal table of contents for a given user linked with a given book +/// +public class AppUserTableOfContent : IEntityDate +{ + public int Id { 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; } + + public required int SeriesId { get; set; } + public virtual Series Series { get; set; } + + public required int ChapterId { get; set; } + public virtual Chapter Chapter { get; set; } + + 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; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } + + // Relationships + /// + /// Navigational Property for EF. Links to a unique AppUser + /// + public AppUser AppUser { get; set; } = null!; + /// + /// User this table of content belongs to + /// + public int AppUserId { get; set; } +} diff --git a/API/Entities/Scrobble/ScrobbleEvent.cs b/API/Entities/Scrobble/ScrobbleEvent.cs index 29436283a..2fd36eef3 100644 --- a/API/Entities/Scrobble/ScrobbleEvent.cs +++ b/API/Entities/Scrobble/ScrobbleEvent.cs @@ -46,6 +46,7 @@ public class ScrobbleEvent : IEntityDate /// public DateTime? ProcessDateUtc { get; set; } + public required int SeriesId { get; set; } public Series Series { get; set; } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 38edabbca..c42a09eff 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Data.Migrations; using API.DTOs; using API.DTOs.Account; using API.DTOs.CollectionTags; @@ -19,6 +20,10 @@ using API.Entities.Metadata; using API.Entities.Scrobble; using API.Helpers.Converters; using AutoMapper; +using CollectionTag = API.Entities.CollectionTag; +using MediaError = API.Entities.MediaError; +using PublicationStatus = API.Entities.Enums.PublicationStatus; +using SiteTheme = API.Entities.SiteTheme; namespace API.Helpers; @@ -211,6 +216,7 @@ public class AutoMapperProfiles : Profile .ConvertUsing(); CreateMap(); + CreateMap(); } } diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs new file mode 100644 index 000000000..b379242ac --- /dev/null +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -0,0 +1,36 @@ +using System.Linq; +using API.DTOs; +using API.Entities; +using API.Services.Plus; + +namespace API.Helpers.Builders; + +public class PlusSeriesDtoBuilder : IEntityBuilder +{ + private readonly PlusSeriesDto _seriesDto; + public PlusSeriesDto Build() => _seriesDto; + + /// + /// This must be a FULL Series + /// + /// + public PlusSeriesDtoBuilder(Series series) + { + _seriesDto = new PlusSeriesDto() + { + MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + SeriesName = series.Name, + AltSeriesName = series.LocalizedName, + AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, + ScrobblingService.AniListWeblinkWebsite), + MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, + ScrobblingService.MalWeblinkWebsite), + GoogleBooksId = ScrobblingService.ExtractId(series.Metadata.WebLinks, + ScrobblingService.GoogleBooksWeblinkWebsite), + VolumeCount = series.Volumes.Count, + ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial), + Year = series.Metadata.ReleaseYear + }; + } + +} diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 3459cbdf0..95863e263 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; -using System.Web; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; @@ -898,7 +897,7 @@ public class BookService : IBookService /// Epub mappings /// Page number we are loading /// - public 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) { await InlineStyles(doc, book, apiBase, body); diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 43f181016..d7d74f77d 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -26,7 +26,6 @@ public class StartupTasksHostedService : IHostedService taskScheduler.ScheduleUpdaterTasks(); - try { // These methods will automatically check if stat collection is disabled to prevent sending any data regardless diff --git a/API/Services/Plus/RatingService.cs b/API/Services/Plus/RatingService.cs index e2bb5eae3..0993948fd 100644 --- a/API/Services/Plus/RatingService.cs +++ b/API/Services/Plus/RatingService.cs @@ -9,6 +9,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Helpers.Builders; using Flurl.Http; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -59,19 +60,7 @@ public class RatingService : IRatingService .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(new PlusSeriesDto() - { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), - SeriesName = series.Name, - AltSeriesName = series.LocalizedName, - AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.AniListWeblinkWebsite), - MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.MalWeblinkWebsite), - VolumeCount = series.Volumes.Count, - ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial), - Year = series.Metadata.ReleaseYear - }) + .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) .ReceiveJson>(); } catch (Exception e) diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs index 8da137b00..f3ca6276e 100644 --- a/API/Services/Plus/RecommendationService.cs +++ b/API/Services/Plus/RecommendationService.cs @@ -11,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; using Flurl.Http; using Kavita.Common; @@ -24,6 +25,7 @@ public record PlusSeriesDto { public int? AniListId { get; set; } public long? MalId { get; set; } + public string? GoogleBooksId { get; set; } public string SeriesName { get; set; } public string? AltSeriesName { get; set; } public MediaFormat MediaFormat { get; set; } @@ -134,19 +136,7 @@ public class RecommendationService : IRecommendationService .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(new PlusSeriesDto() - { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), - SeriesName = series.Name, - AltSeriesName = series.LocalizedName, - AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.AniListWeblinkWebsite), - MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.MalWeblinkWebsite), - VolumeCount = series.Volumes.Count, - ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial), - Year = series.Metadata.ReleaseYear - }) + .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) .ReceiveJson>(); } diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 9730cb9c6..cfb3ed9d8 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -63,11 +63,14 @@ public class ScrobblingService : IScrobblingService public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; + public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id="; private static readonly IDictionary WeblinkExtractionMap = new Dictionary() { {AniListWeblinkWebsite, 0}, {MalWeblinkWebsite, 0}, + {GoogleBooksWeblinkWebsite, 0}, + }; private const int ScrobbleSleepTime = 700; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) @@ -208,8 +211,8 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.Review, - AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), + MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), ReviewBody = reviewBody, @@ -253,8 +256,8 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ScoreUpdated, - AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), + MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), Rating = rating @@ -310,8 +313,8 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ChapterRead, - AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), + MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), AppUserId = userId, VolumeNumber = await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), @@ -353,8 +356,8 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead, - AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), + MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), }; @@ -542,7 +545,7 @@ public class ScrobblingService : IScrobblingService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ProcessUpdatesSinceLastSync() { - // Check how many scrobbles we have available then only do those. + // Check how many scrobble events we have available then only do those. _logger.LogInformation("Starting Scrobble Processing"); var userRateLimits = new Dictionary(); var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); @@ -623,7 +626,7 @@ public class ScrobblingService : IScrobblingService readEvt.AppUser.Id); _unitOfWork.ScrobbleRepository.Update(readEvt); } - progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto() + progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() { Format = evt.Format, AniListId = evt.AniListId, @@ -634,12 +637,14 @@ public class ScrobblingService : IScrobblingService AniListToken = evt.AppUser.AniListAccessToken, SeriesName = evt.Series.Name, LocalizedSeriesName = evt.Series.LocalizedName, - StartedReadingDateUtc = evt.CreatedUtc, ScrobbleDateUtc = evt.LastModifiedUtc, - Year = evt.Series.Metadata.ReleaseYear + Year = evt.Series.Metadata.ReleaseYear, + StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), + LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), }); - progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto() + progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, + totalProgress, evt => Task.FromResult(new ScrobbleDto() { Format = evt.Format, AniListId = evt.AniListId, @@ -650,9 +655,10 @@ public class ScrobblingService : IScrobblingService LocalizedSeriesName = evt.Series.LocalizedName, Rating = evt.Rating, Year = evt.Series.Metadata.ReleaseYear - }); + })); - progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto() + progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter, + totalProgress, evt => Task.FromResult(new ScrobbleDto() { Format = evt.Format, AniListId = evt.AniListId, @@ -665,21 +671,22 @@ public class ScrobblingService : IScrobblingService Year = evt.Series.Metadata.ReleaseYear, ReviewBody = evt.ReviewBody, ReviewTitle = evt.ReviewTitle - }); + })); - progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Year = evt.Series.Metadata.ReleaseYear - }); + progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, + totalProgress, evt => Task.FromResult(new ScrobbleDto() + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + Year = evt.Series.Metadata.ReleaseYear + })); } catch (FlurlHttpException) { @@ -693,7 +700,7 @@ public class ScrobblingService : IScrobblingService } private async Task ProcessEvents(IEnumerable events, IDictionary userRateLimits, - int usersToScrobble, int progressCounter, int totalProgress, Func createEvent) + int usersToScrobble, int progressCounter, int totalProgress, Func> createEvent) { var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); foreach (var evt in events) @@ -714,7 +721,7 @@ public class ScrobblingService : IScrobblingService try { - var data = createEvent(evt); + var data = await createEvent(evt); userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt); evt.IsProcessed = true; evt.ProcessDateUtc = DateTime.UtcNow; @@ -784,17 +791,31 @@ public class ScrobblingService : IScrobblingService /// /// /// - public static long? ExtractId(string webLinks, string website) + public static T? ExtractId(string webLinks, string website) { var index = WeblinkExtractionMap[website]; foreach (var webLink in webLinks.Split(',')) { if (!webLink.StartsWith(website)) continue; var tokens = webLink.Split(website)[1].Split('/'); - return long.Parse(tokens[index]); + var value = tokens[index]; + if (typeof(T) == typeof(int)) + { + if (int.TryParse(value, out var intValue)) + return (T)(object)intValue; + } + else if (typeof(T) == typeof(long)) + { + if (long.TryParse(value, out var longValue)) + return (T)(object)longValue; + } + else if (typeof(T) == typeof(string)) + { + return (T)(object)value; + } } - return 0; + return default(T?); } private async Task SetAndCheckRateLimit(IDictionary userRateLimits, AppUser user, string license) diff --git a/API/Services/ReviewService.cs b/API/Services/ReviewService.cs index a0f4d18f5..6ad170df8 100644 --- a/API/Services/ReviewService.cs +++ b/API/Services/ReviewService.cs @@ -9,6 +9,7 @@ using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Helpers.Builders; using API.Services.Plus; using Flurl.Http; using HtmlAgilityPack; @@ -133,19 +134,7 @@ public class ReviewService : IReviewService .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(new PlusSeriesDto() - { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), - SeriesName = series.Name, - AltSeriesName = series.LocalizedName, - AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.AniListWeblinkWebsite), - MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, - ScrobblingService.MalWeblinkWebsite), - VolumeCount = series.Volumes.Count, - ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial), - Year = series.Metadata.ReleaseYear - }) + .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) .ReceiveJson>(); } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 4cbe433a0..4e7ea9f65 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -61,6 +61,7 @@ public class TaskScheduler : ITaskScheduler public const string DefaultQueue = "default"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; public const string UpdateYearlyStatsTaskId = "update-yearly-stats"; + public const string CheckForUpdateId = "check-updates"; public const string CleanupDbTaskId = "cleanup-db"; public const string CleanupTaskId = "cleanup"; public const string BackupTaskId = "backup"; @@ -226,10 +227,8 @@ public class TaskScheduler : ITaskScheduler public void ScheduleUpdaterTasks() { _logger.LogInformation("Scheduling Auto-Update tasks"); - RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(5, 23)), new RecurringJobOptions() - { - TimeZone = TimeZoneInfo.Local - }); + RecurringJob.AddOrUpdate(CheckForUpdateId, () => CheckForUpdate(), $"0 */{Rnd.Next(4, 6)} * * *", RecurringJobOptions); + BackgroundJob.Enqueue(() => CheckForUpdate()); } public void ScanFolder(string folderPath, TimeSpan delay) diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index e9684e4e8..3b1f7746c 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -145,7 +145,7 @@ public class BackupService : IBackupService private void CopyFaviconsToBackupDirectory(string tempDirectory) { - _directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, tempDirectory); + _directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "favicons")); } private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index bfb4a34c7..ea3c64699 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -72,13 +72,11 @@ public class VersionUpdaterService : IVersionUpdaterService /// /// Fetches the latest release from Github /// - /// Latest update or null if current version is greater than latest update - public async Task CheckForUpdate() + /// Latest update + public async Task CheckForUpdate() { var update = await GetGithubRelease(); - var dto = CreateDto(update); - if (dto == null) return null; - return new Version(dto.UpdateVersion) <= new Version(dto.CurrentVersion) ? null : dto; + return CreateDto(update); } public async Task> GetAllReleases() diff --git a/API/config/appsettings.json b/API/config/appsettings.json index e04e9eaa4..3eeee1c18 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -1,5 +1,5 @@ { - "TokenKey": "super secret unguessable key", + "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "", "BaseUrl": "/", diff --git a/UI/Web/src/app/_models/readers/personal-toc.ts b/UI/Web/src/app/_models/readers/personal-toc.ts new file mode 100644 index 000000000..3d4c3c9af --- /dev/null +++ b/UI/Web/src/app/_models/readers/personal-toc.ts @@ -0,0 +1,8 @@ +export interface PersonalToC { + chapterId: number; + pageNumber: number; + title: string; + bookScrollId: string | undefined; + /* Ui Only */ + position: 0; +} diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 0aec8f647..f94fba4fd 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpParams } from '@angular/common/http'; -import {DestroyRef, inject, Injectable} from '@angular/core'; -import { Location } from '@angular/common'; +import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; +import {DOCUMENT, Location} from '@angular/common'; import { Router } from '@angular/router'; import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; @@ -17,9 +17,8 @@ import { FileDimension } from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; import { TextResonse } from '../_types/text-response'; import { AccountService } from './account.service'; -import { Subject, takeUntil } from 'rxjs'; -import { OnDestroy } from '@angular/core'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {PersonalToC} from "../_models/readers/personal-toc"; export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_NOT_FETCHED = -2; @@ -279,4 +278,51 @@ export class ReaderService { this.location.back(); } } + + removePersonalToc(chapterId: number, pageNumber: number, title: string) { + return this.httpClient.delete(this.baseUrl + `reader/ptoc?chapterId=${chapterId}&pageNum=${pageNumber}&title=${encodeURIComponent(title)}`); + } + + getPersonalToC(chapterId: number) { + 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}); + } + + getElementFromXPath(path: string) { + const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (node?.nodeType === Node.ELEMENT_NODE) { + return node as Element; + } + return null; + } + + /** + * + * @param element + * @param pureXPath Will ignore shortcuts like id('') + */ + getXPathTo(element: any, pureXPath = false): string { + if (element === null) return ''; + if (!pureXPath) { + if (element.id !== '') { return 'id("' + element.id + '")'; } + if (element === document.body) { return element.tagName; } + } + + + let ix = 0; + const siblings = element.parentNode?.childNodes || []; + for (let sibling of siblings) { + if (sibling === element) { + return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; + } + if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { + ix++; + } + + } + return ''; + } } diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index e815410f7..9edca977c 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -1,32 +1,20 @@ import {HttpClient, HttpParams} from '@angular/common/http'; -import {DestroyRef, inject, Injectable, OnDestroy} from '@angular/core'; -import { of, ReplaySubject, Subject } from 'rxjs'; -import { filter, map, switchMap, takeUntil } from 'rxjs/operators'; +import {Injectable} from '@angular/core'; +import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; -import { Preferences } from '../_models/preferences/preferences'; -import { User } from '../_models/user'; -import { Router } from '@angular/router'; -import { EVENTS, MessageHubService } from './message-hub.service'; -import { ThemeService } from './theme.service'; -import { InviteUserResponse } from '../_models/auth/invite-user-response'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { UpdateEmailResponse } from '../_models/auth/update-email-response'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRestriction } from '../_models/metadata/age-restriction'; import { TextResonse } from '../_types/text-response'; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleError} from "../_models/scrobbling/scrobble-error"; import {ScrobbleEvent} from "../_models/scrobbling/scrobble-event"; import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold"; -import {PaginatedResult, Pagination} from "../_models/pagination"; +import {PaginatedResult} from "../_models/pagination"; import {ScrobbleEventFilter} from "../_models/scrobbling/scrobble-event-filter"; import {UtilityService} from "../shared/_services/utility.service"; -import {ReadingList} from "../_models/reading-list"; export enum ScrobbleProvider { Kavita = 0, AniList= 1, Mal = 2, + GoogleBooks = 3 } @Injectable({ @@ -34,7 +22,6 @@ export enum ScrobbleProvider { }) export class ScrobblingService { - private readonly destroyRef = inject(DestroyRef); baseUrl = environment.apiUrl; diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html index 266e2c43e..82af8c73f 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html @@ -13,7 +13,7 @@ Use fully qualified URL of the email service. Do not include ending slash.
- + diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.html b/UI/Web/src/app/admin/manage-system/manage-system.component.html index 15f4abea1..e917bccf9 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.html +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.html @@ -13,29 +13,30 @@

More Info


-
Home page:
-
-
-
Wiki:
- -
-
-
Discord:
- -
-
-
Donations:
- -
- -
-
Feature Requests:
- +
+
+
Wiki:
+ +
+
+
Discord:
+ +
+
+
Donations:
+ +
+ +
+
Feature Requests:
+ + +
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 new file mode 100644 index 000000000..2e4aefafb --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.html @@ -0,0 +1,31 @@ +
+ + + + +
+ +
+
+ +
+
+ + +
+
+ This field is required +
+
+
+
+
+
+ + +
diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.scss b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.scss new file mode 100644 index 000000000..e97a10436 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.scss @@ -0,0 +1,9 @@ +.overlay { + position: absolute; + background-color: rgb(0, 0, 0); + color: white; + padding: 5px; + border-radius: 4px; + max-width: 285px; /* Optional: limit the width of the overlay box */ + z-index: 9999; /* Ensure it's displayed above other elements */ +} 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 new file mode 100644 index 000000000..a985f1bec --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -0,0 +1,128 @@ +import { + ChangeDetectionStrategy, ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, EventEmitter, + inject, + Input, + OnInit, Output, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {fromEvent, of} from "rxjs"; +import {catchError, filter, tap} from "rxjs/operators"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import getBoundingClientRect from "@popperjs/core/lib/dom-utils/getBoundingClientRect"; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import {ReaderService} from "../../../_services/reader.service"; + +enum BookLineOverlayMode { + None = 0, + Bookmark = 1 +} + +@Component({ + selector: 'app-book-line-overlay', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './book-line-overlay.component.html', + styleUrls: ['./book-line-overlay.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BookLineOverlayComponent implements OnInit { + @Input({required: true}) libraryId!: number; + @Input({required: true}) seriesId!: number; + @Input({required: true}) volumeId!: number; + @Input({required: true}) chapterId!: number; + @Input({required: true}) pageNumber: number = 0; + @Input({required: true}) parent: ElementRef | undefined; + @Output() refreshToC: EventEmitter = new EventEmitter(); + + xPath: string = ''; + selectedText: string = ''; + overlayPosition: { top: number; left: number } = { top: 0, left: 0 }; + mode: BookLineOverlayMode = BookLineOverlayMode.None; + bookmarkForm: FormGroup = new FormGroup({ + name: new FormControl('', [Validators.required]), + }); + + private readonly destroyRef = inject(DestroyRef); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly readerService = inject(ReaderService); + + get BookLineOverlayMode() { return BookLineOverlayMode; } + constructor(private elementRef: ElementRef) {} + + + ngOnInit() { + if (this.parent) { + fromEvent(this.parent.nativeElement, 'mouseup') + .pipe(takeUntilDestroyed(this.destroyRef), + tap((event: MouseEvent) => { + const selection = window.getSelection(); + if (!event.target) return; + + if (this.mode !== BookLineOverlayMode.None && (!selection || selection.toString().trim() === '')) { + this.reset(); + return; + } + + this.selectedText = selection ? selection.toString().trim() : ''; + + if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) { + // Get x,y coord so we can position overlay + if (event.target) { + const range = selection!.getRangeAt(0) + const rect = range.getBoundingClientRect(); + const box = getBoundingClientRect(event.target as Element); + this.xPath = this.readerService.getXPathTo(event.target); + if (this.xPath !== '') { + this.xPath = '//' + this.xPath; + } + + this.overlayPosition = { + top: rect.top + window.scrollY - 64 - rect.height, // 64px is the top menu area + left: rect.left + window.scrollX + 30 // Adjust 10 to center the overlay box horizontally + }; + } + } + this.cdRef.markForCheck(); + })) + .subscribe(); + } + } + + switchMode(mode: BookLineOverlayMode) { + this.mode = mode; + this.cdRef.markForCheck(); + if (this.mode === BookLineOverlayMode.Bookmark) { + this.bookmarkForm.get('name')?.setValue(this.selectedText); + this.focusOnBookmarkInput(); + } + } + + 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.focusOnBookmarkInput(); + return of(); + })).subscribe(() => { + this.reset(); + this.refreshToC.emit(); + this.cdRef.markForCheck(); + }); + } + + focusOnBookmarkInput() { + if (this.mode !== BookLineOverlayMode.Bookmark) return; + setTimeout(() => this.elementRef.nativeElement.querySelector('#bookmark-name')?.focus(), 10); + } + + reset() { + this.bookmarkForm.reset(); + this.mode = BookLineOverlayMode.None; + this.xPath = ''; + this.selectedText = ''; + this.cdRef.markForCheck(); + } + +} 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 dc22194f6..94f3dafa6 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 @@ -63,7 +63,23 @@
  • Table of Contents - + +
  • @@ -90,8 +106,6 @@ [ngStyle]="{'max-height': ColumnHeight, 'max-width': VerticalBookContentWidth, 'width': VerticalBookContentWidth, 'column-width': ColumnWidth}" [ngClass]="{'immersive': immersiveMode && actionBarVisible}" [innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)">
    - -
    @@ -99,7 +113,6 @@
    -
    + + + 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 506571b33..8ee5d89c8 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,7 +3,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, - ElementRef, + ElementRef, EventEmitter, HostListener, inject, Inject, @@ -16,8 +16,8 @@ import { import { DOCUMENT, Location, NgTemplateOutlet, NgIf, NgStyle, NgClass } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin, fromEvent, of, Subject } from 'rxjs'; -import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators'; +import { forkJoin, fromEvent, of } from 'rxjs'; +import { catchError, debounceTime, take } from 'rxjs/operators'; import { Chapter } from 'src/app/_models/chapter'; import { AccountService } from 'src/app/_services/account.service'; import { NavService } from 'src/app/_services/nav.service'; @@ -46,13 +46,20 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { TableOfContentsComponent } from '../table-of-contents/table-of-contents.component'; import { NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { DrawerComponent } from '../../../shared/drawer/drawer.component'; +import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component"; +import { + PersonalTableOfContentsComponent, + PersonalToCEvent +} from "../personal-table-of-contents/personal-table-of-contents.component"; enum TabID { Settings = 1, - TableOfContents = 2 + TableOfContents = 2, + PersonalTableOfContents = 3 } + interface HistoryPoint { /** * Page Number @@ -94,7 +101,7 @@ const elementLevelStyles = ['line-height', 'font-family']; ]) ], standalone: true, - imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip] + imports: [NgTemplateOutlet, DrawerComponent, NgIf, NgbProgressbar, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, ReaderSettingsComponent, TableOfContentsComponent, NgbNavOutlet, NgStyle, NgClass, NgbTooltip, BookLineOverlayComponent, PersonalTableOfContentsComponent] }) export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -150,6 +157,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Belongs to the drawer component */ activeTabId: TabID = TabID.Settings; + /** + * Sub Nav tab id + */ + tocId: TabID = TabID.TableOfContents; /** * Belongs to drawer component */ @@ -280,6 +291,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { writingStyle: WritingStyle = WritingStyle.Horizontal; + /** + * Used to refresh the Personal PoC + */ + refreshPToC: EventEmitter = new EventEmitter(); + private readonly destroyRef = inject(DestroyRef); @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; @@ -666,6 +682,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @HostListener('window:keydown', ['$event']) handleKeyPress(event: KeyboardEvent) { + const activeElement = document.activeElement as HTMLElement; + const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA'; + if (isInputFocused) return; + if (event.key === KEY_CODES.RIGHT_ARROW) { this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); } else if (event.key === KEY_CODES.LEFT_ARROW) { @@ -783,6 +803,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { 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 * from 'kavita-part', which will cause the reader to scroll to the marker. @@ -987,7 +1016,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } else { - this.reader.nativeElement.children // We need to check if we are paging back, because we need to adjust the scroll if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); @@ -1213,7 +1241,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { intersectingEntries.sort(this.sortElements); if (intersectingEntries.length > 0) { - let path = this.getXPathTo(intersectingEntries[0]); + let path = this.readerService.getXPathTo(intersectingEntries[0]); if (path === '') { return; } if (!path.startsWith('id')) { path = '//html[1]/' + path; @@ -1339,35 +1367,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - getElementFromXPath(path: string) { - const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const node = this.document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (node?.nodeType === Node.ELEMENT_NODE) { return node as Element; } return null; } - getXPathTo(element: any): string { - if (element === null) return ''; - if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === this.document.body) { return element.tagName; } - - - let ix = 0; - const siblings = element.parentNode?.childNodes || []; - for (let sibling of siblings) { - if (sibling === element) { - return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; - } - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { - ix++; - } - - } - return ''; - } - /** * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state */ @@ -1583,4 +1590,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.mousePosition.x = $event.screenX; this.mousePosition.y = $event.screenY; } + + refreshPersonalToC() { + this.refreshPToC.emit(); + } + } 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 new file mode 100644 index 000000000..a8213b027 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.html @@ -0,0 +1,22 @@ +
    +
    + Nothing Bookmarked yet +
    +
      +
    • + Page {{page}} +
        +
      • + {{bookmark.title}} + +
      • +
      +
    • +
    +
    diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.scss b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.scss new file mode 100644 index 000000000..86163686d --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.scss @@ -0,0 +1,15 @@ +.table-of-contents li { + cursor: pointer; + + &.active { + font-weight: bold; + } +} + +.chapter-title { + padding-inline-start: 1rem; +} +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; +} 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 new file mode 100644 index 000000000..9e7555639 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, EventEmitter, + Inject, + inject, + Input, + OnInit, + Output +} from '@angular/core'; +import {CommonModule, 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"; + +export interface PersonalToCEvent { + pageNum: number; + scrollPart: string | undefined; +} + +@Component({ + selector: 'app-personal-table-of-contents', + standalone: true, + imports: [CommonModule, NgbTooltip], + templateUrl: './personal-table-of-contents.component.html', + styleUrls: ['./personal-table-of-contents.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PersonalTableOfContentsComponent implements OnInit { + + @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) {} + + ngOnInit() { + this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.load(); + }); + + this.load(); + } + + 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(); + }); + } + + loadChapterPage(pageNum: number, scrollPart: string | undefined) { + 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); + + if (this.bookmarks[bookmark.pageNumber].length === 0) { + delete this.bookmarks[bookmark.pageNumber]; + } + this.cdRef.markForCheck(); + }); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index 9177b59dd..cc8a22995 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -1,5 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; -import { Subject } from 'rxjs'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { BookChapterItem } from '../../_models/book-chapter-item'; import { NgIf, NgFor } from '@angular/common'; @@ -11,7 +10,7 @@ import { NgIf, NgFor } from '@angular/common'; standalone: true, imports: [NgIf, NgFor] }) -export class TableOfContentsComponent implements OnDestroy { +export class TableOfContentsComponent { @Input({required: true}) chapterId!: number; @Input({required: true}) pageNum!: number; @@ -20,17 +19,8 @@ export class TableOfContentsComponent implements OnDestroy { @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); - private onDestroy: Subject = new Subject(); - - pageAnchors: {[n: string]: number } = {}; - constructor() {} - ngOnDestroy(): void { - this.onDestroy.next(); - this.onDestroy.complete(); - } - cleanIdSelector(id: string) { const tokens = id.split('/'); if (tokens.length > 0) { 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 4ca377636..65549ab48 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -5,12 +5,6 @@ import { environment } from 'src/environments/environment'; import { BookChapterItem } from '../_models/book-chapter-item'; import { BookInfo } from '../_models/book-info'; -export interface BookPage { - bookTitle: string; - styles: string; - html: string; -} - export interface FontFamily { /** * What the user should see @@ -32,7 +26,7 @@ export class BookService { constructor(private http: HttpClient) { } getFontFamilies(): Array { - return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, + 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: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}]; } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index f0e757c54..81ce522e2 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -72,6 +72,7 @@ export class DashboardComponent implements OnInit { this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { + if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return; this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries]; this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts index ff462febb..b5da925c0 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts @@ -9,8 +9,8 @@ import { OnInit } from '@angular/core'; import { NgbModal, NgbModalRef, NgbPopover } from '@ng-bootstrap/ng-bootstrap'; -import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; -import { map, shareReplay, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { map, shareReplay } from 'rxjs/operators'; import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component'; @@ -79,7 +79,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { + this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event: Message) => { if (event.event === EVENTS.NotificationProgress) { this.processNotificationProgressEvent(event); } else if (event.event === EVENTS.Error) { @@ -94,6 +94,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { this.infoSource.next(values); this.activeEvents += 1; this.cdRef.markForCheck(); + } else if (event.event === EVENTS.UpdateAvailable) { + console.log('event: ', event); + this.handleUpdateAvailableClick(event.payload); } }); @@ -150,10 +153,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { } - handleUpdateAvailableClick(message: NotificationProgressEvent) { + handleUpdateAvailableClick(message: NotificationProgressEvent | UpdateVersionEvent) { if (this.updateNotificationModalRef != null) { return; } this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); - this.updateNotificationModalRef.componentInstance.updateData = message.body as UpdateVersionEvent; + if (message.hasOwnProperty('body')) { + this.updateNotificationModalRef.componentInstance.updateData = (message as NotificationProgressEvent).body as UpdateVersionEvent; + } else { + this.updateNotificationModalRef.componentInstance.updateData = message as UpdateVersionEvent; + } + this.updateNotificationModalRef.closed.subscribe(() => { this.updateNotificationModalRef = null; }); @@ -176,7 +184,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { } config.header = event.title; config.content = event.subTitle; - var result = await this.confirmService.alert(event.subTitle || event.title, config); + const result = await this.confirmService.alert(event.subTitle || event.title, config); if (result) { this.removeErrorOrInfo(event); } diff --git a/UI/Web/src/app/pipe/provider-image.pipe.ts b/UI/Web/src/app/pipe/provider-image.pipe.ts index edb7b63b1..0697e49e5 100644 --- a/UI/Web/src/app/pipe/provider-image.pipe.ts +++ b/UI/Web/src/app/pipe/provider-image.pipe.ts @@ -13,6 +13,8 @@ export class ProviderImagePipe implements PipeTransform { return 'assets/images/ExternalServices/AniList.png'; case ScrobbleProvider.Mal: return 'assets/images/ExternalServices/MAL.png'; + case ScrobbleProvider.GoogleBooks: + return 'assets/images/ExternalServices/GoogleBooks.png'; case ScrobbleProvider.Kavita: return 'assets/images/logo-32.png'; } diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html index af2b2c1d8..59527fd09 100644 --- a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html @@ -1,8 +1,6 @@ \ No newline at end of file + How to Update + + Download + diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts index fa5f3ed2b..6ac65b7d9 100644 --- a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap'; import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event'; import {CommonModule} from "@angular/common"; @@ -14,12 +14,21 @@ import {SafeHtmlPipe} from "../../pipe/safe-html.pipe"; styleUrls: ['./update-notification-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class UpdateNotificationModalComponent { +export class UpdateNotificationModalComponent implements OnInit { @Input({required: true}) updateData!: UpdateVersionEvent; + updateUrl: string = 'https://wiki.kavitareader.com/en/install/windows-install#updating-kavita'; constructor(public modal: NgbActiveModal) { } + ngOnInit() { + if (this.updateData.isDocker) { + this.updateUrl = 'https://wiki.kavitareader.com/en/install/docker-install#updating-kavita'; + } else { + this.updateUrl = 'https://wiki.kavitareader.com/en/install/windows-install#updating-kavita'; + } + } + close() { this.modal.close({success: false, series: undefined}); } diff --git a/UI/Web/src/assets/images/ExternalServices/GoogleBooks.png b/UI/Web/src/assets/images/ExternalServices/GoogleBooks.png new file mode 100644 index 0000000000000000000000000000000000000000..8d75d021db25b29b2a928dd992605ae629dc2330 GIT binary patch literal 523 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy2LgOTT!A!O*#WM&dF;h|7$Rmc zBrfHSoo|2V9}N9>ybWS=#Q_EX+u!|XcjupA+S0OD|M*jvJ3z#5|KkE`x&6=i=5L@% zhr9or@BVx7;fKujuk1y;7-oHNy!>6ga3yQaaiGZ-)f?GL_Bb|fVVL~^sM4o%yU2#m zk}F>THQL?&SF-2?SKM5N*>5Lqxy&%`9Li)+rPC6U-{{FxIti#)P{QxvNk(CkYxIs z%TzJpr6%Kq#-k2eTs@^C65OwiC-yT;J;unuSjx-5xQMHPVc|;V_*snkdw2!d%K0~Z zlo04S-rQ&2*KluApK?MOi^G8wevf54y?@O4;H+QtA*6_X>RWE0cNjcf{an^LB{Ts5 Dz_swN literal 0 HcmV?d00001 diff --git a/UI/Web/src/assets/images/ExternalServices/OpenLibrary.png b/UI/Web/src/assets/images/ExternalServices/OpenLibrary.png new file mode 100644 index 0000000000000000000000000000000000000000..453ecdab922aa266b8b52d5ffa0ce17822f428a7 GIT binary patch literal 1071 zcmV+~1kn45P)hht(+mE=^hO*F=h;xg4XM$~4X;wi{H6iu*`|I-e+2`=I&*GcF*m_(pb6$mTTYhU-a$ZSDLp@(d zGfXlb=kfOA?ey5@@Y3V%ywv5a%HNi}){M5&fUeDOrN~v2z0}UQ)6KTb#IDJ~t4V{X zgq)+kwxC3GlZB9vM{(dw3;v%bce|Dzk{z~eHfvj*&Dm_LshsWAo5xk~#E)VAv_Y@^ z`&->?Qfe_>i&Ay^Oj8wj^q96ZW!Uxp@fA-5J~gH)PV9ZqVAzKC1Yq1(Ve0kq&w2#Z z`P6ZMmo%6zLmSGVIbA#kOxqtnt>Y6iF!{EQf*FWn@6J74UU~h#u=p##bp+huMux5M zu;ldIOf3+n%>%=|v7rn75q8kwv|eWs+NlAGWPpVchuOsjVtu0{@z51`ajc;F_PqiY z*#HL}kxQXIb2J!sa6$W+J+Y(u@v{P6z=5!{C*B`}&=s~`Ok{ld;Z4Fd(Rgh zBrEck1llbYz%YzkhiMLG8D_FP$*+a^w=2sppOpc_0E(hE(O2$|Q`D3^>WuVRZV7Zy z6d*}*4;_7I$7RqWfwnRrNkG#$y6Zb>nkHn>S_L_p20WkkYcPL?=XpgfI4glpo(F=^ zj^0>m6a=9`2JS;32mr^|w3tedqwdR~O#)34Si^BZ5QO59+e4rl@*7^Sw|moc{#;l0 plC-oE1W?ooG}H;4suO6e(?9+LWenQ*f`kA7002ovPDHLkV1l(E3cdgU literal 0 HcmV?d00001 diff --git a/UI/Web/src/environments/environment.prod.ts b/UI/Web/src/environments/environment.prod.ts index b4d9702ce..54642eacc 100644 --- a/UI/Web/src/environments/environment.prod.ts +++ b/UI/Web/src/environments/environment.prod.ts @@ -5,6 +5,6 @@ export const environment = { production: true, apiUrl: `${BASE_URL}api/`, hubUrl:`${BASE_URL}hubs/`, - buyLink: 'https://buy.stripe.com/fZe6qsbrJ8bye88cMO?prefilled_promo_code=FREETRIAL', + buyLink: 'https://buy.stripe.com/3cs7uw67p2Re7JK4gj?prefilled_promo_code=FREETRIAL', manageLink: 'https://billing.stripe.com/p/login/28oaFRa3HdHWb5ecMM' }; diff --git a/UI/Web/src/theme/components/_anchors.scss b/UI/Web/src/theme/components/_anchors.scss index 35b45980b..950732f55 100644 --- a/UI/Web/src/theme/components/_anchors.scss +++ b/UI/Web/src/theme/components/_anchors.scss @@ -26,6 +26,10 @@ a.read-more-link { } } +td > a:not(.dark-exempt) { + color: var(--primary-color-darker-shade); +} + a { text-decoration: none; diff --git a/openapi.json b/openapi.json index 03774ecff..9f7acc96d 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.4.5" + "version": "0.7.5.1" }, "servers": [ { @@ -5248,6 +5248,125 @@ } } }, + "/api/Reader/ptoc": { + "get": { + "tags": [ + "Reader" + ], + "summary": "Returns the user's personal table of contents for the given chapter", + "parameters": [ + { + "name": "chapterId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonalToCDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonalToCDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonalToCDto" + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Reader" + ], + "parameters": [ + { + "name": "chapterId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "title", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/Reader/create-ptoc": { + "post": { + "tags": [ + "Reader" + ], + "summary": "Create a new personal table of content entry for a given chapter", + "description": "The title and page number must be unique to that book", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalToCDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalToCDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreatePersonalToCDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/ReadingList": { "get": { "tags": [ @@ -10811,6 +10930,14 @@ "description": "A list of Devices which allows the user to send files to", "nullable": true }, + "tableOfContents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppUserTableOfContent" + }, + "description": "A list of Table of Contents for a given Chapter", + "nullable": true + }, "apiKey": { "type": "string", "description": "An API Key to interact with external services, like OPDS", @@ -11230,6 +11357,78 @@ }, "additionalProperties": false }, + "AppUserTableOfContent": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "pageNumber": { + "type": "integer", + "description": "The page to bookmark", + "format": "int32" + }, + "title": { + "type": "string", + "description": "The title of the bookmark. Defaults to Page {PageNumber} if not set", + "nullable": true + }, + "seriesId": { + "type": "integer", + "format": "int32" + }, + "series": { + "$ref": "#/components/schemas/Series" + }, + "chapterId": { + "type": "integer", + "format": "int32" + }, + "chapter": { + "$ref": "#/components/schemas/Chapter" + }, + "volumeId": { + "type": "integer", + "format": "int32" + }, + "libraryId": { + "type": "integer", + "format": "int32" + }, + "bookScrollId": { + "type": "string", + "description": "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", + "nullable": true + }, + "created": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "appUser": { + "$ref": "#/components/schemas/AppUser" + }, + "appUserId": { + "type": "integer", + "description": "User this table of content belongs to", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "A personal table of contents for a given user linked with a given book" + }, "BookChapterItem": { "type": "object", "properties": { @@ -12368,6 +12567,40 @@ }, "additionalProperties": false }, + "CreatePersonalToCDto": { + "type": "object", + "properties": { + "chapterId": { + "type": "integer", + "format": "int32" + }, + "volumeId": { + "type": "integer", + "format": "int32" + }, + "seriesId": { + "type": "integer", + "format": "int32" + }, + "libraryId": { + "type": "integer", + "format": "int32" + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "bookScrollId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "CreateReadingListDto": { "type": "object", "properties": { @@ -13757,6 +13990,28 @@ }, "additionalProperties": false }, + "PersonalToCDto": { + "type": "object", + "properties": { + "chapterId": { + "type": "integer", + "format": "int32" + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "bookScrollId": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "ProgressDto": { "required": [ "chapterId", @@ -14544,11 +14799,6 @@ "format": "int32", "nullable": true }, - "processDateUtc": { - "type": "string", - "format": "date-time", - "nullable": true - }, "lastModified": { "type": "string", "format": "date-time"