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 000000000..8d75d021d Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/GoogleBooks.png differ 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 000000000..453ecdab9 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/OpenLibrary.png differ 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"