diff --git a/API/API.csproj b/API/API.csproj
index c505640f0..71bd0aa1f 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -81,6 +81,7 @@
+
@@ -91,7 +92,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs
new file mode 100644
index 000000000..f6fcfbb87
--- /dev/null
+++ b/API/Controllers/AnnotationController.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data;
+using API.DTOs.Reader;
+using API.Entities;
+using API.Extensions;
+using API.Helpers;
+using API.Services;
+using API.SignalR;
+using Kavita.Common;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace API.Controllers;
+
+public class AnnotationController : BaseApiController
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly ILogger _logger;
+ private readonly IBookService _bookService;
+ private readonly ILocalizationService _localizationService;
+ private readonly IEventHub _eventHub;
+
+ public AnnotationController(IUnitOfWork unitOfWork, ILogger logger,
+ IBookService bookService, ILocalizationService localizationService, IEventHub eventHub)
+ {
+ _unitOfWork = unitOfWork;
+ _logger = logger;
+ _bookService = bookService;
+ _localizationService = localizationService;
+ _eventHub = eventHub;
+ }
+
+ ///
+ /// Returns the annotations for the given chapter
+ ///
+ ///
+ ///
+ [HttpGet("all")]
+ public async Task>> GetAnnotations(int chapterId)
+ {
+
+ return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
+ }
+
+ ///
+ /// Returns the Annotation by Id. User must have access to annotation.
+ ///
+ ///
+ ///
+ [HttpGet("{annotationId}")]
+ public async Task> GetAnnotation(int annotationId)
+ {
+ return Ok(await _unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId));
+ }
+
+ ///
+ /// Create a new Annotation for the user against a Chapter
+ ///
+ ///
+ ///
+ [HttpPost("create")]
+ public async Task> CreateAnnotation(AnnotationDto dto)
+ {
+ try
+ {
+ if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText))
+ {
+ return BadRequest(_localizationService.Translate(User.GetUserId(), "invalid-payload"));
+ }
+
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
+ if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
+
+ var chapterTitle = string.Empty;
+ try
+ {
+ var toc = await _bookService.GenerateTableOfContents(chapter);
+ var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber);
+ if (pageTocs.Count > 0)
+ {
+ chapterTitle = pageTocs[0].Title;
+ }
+ }
+ catch (KavitaException)
+ {
+ /* Swallow */
+ }
+
+ var annotation = new AppUserAnnotation()
+ {
+ XPath = dto.XPath,
+ EndingXPath = dto.EndingXPath,
+ ChapterId = dto.ChapterId,
+ SeriesId = dto.SeriesId,
+ VolumeId = dto.VolumeId,
+ LibraryId = dto.LibraryId,
+ HighlightCount = dto.HighlightCount,
+ SelectedText = dto.SelectedText,
+ Comment = dto.Comment,
+ ContainsSpoiler = dto.ContainsSpoiler,
+ PageNumber = dto.PageNumber,
+ SelectedSlotIndex = dto.SelectedSlotIndex,
+ AppUserId = User.GetUserId(),
+ Context = dto.Context,
+ ChapterTitle = chapterTitle
+ };
+
+ _unitOfWork.AnnotationRepository.Attach(annotation);
+ await _unitOfWork.CommitAsync();
+
+ return Ok(await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
+ return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-failed-create"));
+ }
+ }
+
+ ///
+ /// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation
+ ///
+ ///
+ ///
+ [HttpPost("update")]
+ public async Task> UpdateAnnotation(AnnotationDto dto)
+ {
+ try
+ {
+ var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id);
+ if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest();
+
+ annotation.ContainsSpoiler = dto.ContainsSpoiler;
+ annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
+ annotation.Comment = dto.Comment;
+ _unitOfWork.AnnotationRepository.Update(annotation);
+
+ if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
+ {
+ await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, MessageFactory.AnnotationUpdateEvent(dto),
+ User.GetUserId());
+ return Ok(dto);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber);
+ return BadRequest();
+ }
+
+ return Ok();
+ }
+
+ ///
+ /// Delete the annotation for the user
+ ///
+ ///
+ ///
+ [HttpDelete]
+ public async Task DeleteAnnotation(int annotationId)
+ {
+ var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(annotationId);
+ if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-delete"));
+
+ _unitOfWork.AnnotationRepository.Remove(annotation);
+ await _unitOfWork.CommitAsync();
+ return Ok();
+ }
+}
diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs
index 7806ef660..bee612fa2 100644
--- a/API/Controllers/BaseApiController.cs
+++ b/API/Controllers/BaseApiController.cs
@@ -10,4 +10,8 @@ namespace API.Controllers;
[Authorize]
public class BaseApiController : ControllerBase
{
+ public BaseApiController()
+ {
+
+ }
}
diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs
index e1d7da9e8..2fcf8567a 100644
--- a/API/Controllers/BookController.cs
+++ b/API/Controllers/BookController.cs
@@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using API.Constants;
using API.Data;
using API.DTOs.Reader;
using API.Entities.Enums;
@@ -34,33 +35,41 @@ public class BookController : BaseApiController
}
///
- /// Retrieves information for the PDF and Epub reader
+ /// Retrieves information for the PDF and Epub reader. This will cache the file.
///
/// This only applies to Epub or PDF files
///
///
[HttpGet("{chapterId}/book-info")]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])]
public async Task> GetBookInfo(int chapterId)
{
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var bookTitle = string.Empty;
+
+
switch (dto.SeriesFormat)
{
case MangaFormat.Epub:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
- using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
+ await _cacheService.Ensure(chapterId);
+ var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath);
+ using var book = await EpubReader.OpenBookAsync(file, BookService.LenientBookReaderOptions);
bookTitle = book.Title;
+
break;
}
case MangaFormat.Pdf:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
+ await _cacheService.Ensure(chapterId);
+ var file = _cacheService.GetCachedFile(chapterId, mangaFile.FilePath);
if (string.IsNullOrEmpty(bookTitle))
{
// Override with filename
- bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
+ bookTitle = Path.GetFileNameWithoutExtension(file);
}
break;
@@ -72,9 +81,9 @@ public class BookController : BaseApiController
break;
}
- return Ok(new BookInfoDto()
+ var info = new BookInfoDto()
{
- ChapterNumber = dto.ChapterNumber,
+ ChapterNumber = dto.ChapterNumber,
VolumeNumber = dto.VolumeNumber,
VolumeId = dto.VolumeId,
BookTitle = bookTitle,
@@ -84,7 +93,10 @@ public class BookController : BaseApiController
LibraryId = dto.LibraryId,
IsSpecial = dto.IsSpecial,
Pages = dto.Pages,
- });
+ };
+
+
+ return Ok(info);
}
///
@@ -157,7 +169,11 @@ public class BookController : BaseApiController
try
{
- return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl));
+ var ptocBookmarks =
+ await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
+ var annotations = await _unitOfWork.UserRepository.GetAnnotationsByPage(User.GetUserId(), chapter.Id, page);
+
+ return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations));
}
catch (KavitaException ex)
{
diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs
index 87e0542d1..95ca5bee6 100644
--- a/API/Controllers/ImageController.cs
+++ b/API/Controllers/ImageController.cs
@@ -193,13 +193,12 @@ public class ImageController : BaseApiController
/// API Key for user. Needed to authenticate request
///
[HttpGet("bookmark")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"
- ])]
- public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey)
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey", "imageOffset"])]
+ public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey, int imageOffset = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
- var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId);
+ var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, imageOffset, userId);
if (bookmark == null) return BadRequest(await _localizationService.Translate(userId, "bookmark-doesnt-exist"));
var bookmarkDirectory =
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index 0d04c181f..0fd9c437a 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -38,7 +38,7 @@ namespace API.Controllers;
#nullable enable
/**
- * Middleware that checks if Opds has been enabled for this server
+ * Middleware that checks if Opds has been enabled for this server, and sets OpdsController.UserId in HttpContext
*/
[AttributeUsage(AttributeTargets.Class)]
public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger): ActionFilterAttribute
@@ -49,14 +49,13 @@ public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationServ
int userId;
try
{
- if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) ||
- apiKeyObj is not string apiKey || context.Controller is not OpdsController controller)
+ if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
{
context.Result = new BadRequestResult();
return;
}
- userId = await controller.GetUser(apiKey);
+ userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == null || userId == 0)
{
context.Result = new UnauthorizedResult();
@@ -1346,7 +1345,7 @@ public class OpdsController : BaseApiController
/// Gets the user from the API key
///
///
- public async Task GetUser(string apiKey)
+ private async Task GetUser(string apiKey)
{
try
{
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 38a5ad482..09790c17b 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json.Nodes;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@@ -15,6 +16,8 @@ using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
+using API.Services.Tasks.Metadata;
+using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Hangfire;
using Kavita.Common;
@@ -41,6 +44,7 @@ public class ReaderController : BaseApiController
private readonly IEventHub _eventHub;
private readonly IScrobblingService _scrobblingService;
private readonly ILocalizationService _localizationService;
+ private readonly IBookService _bookService;
///
public ReaderController(ICacheService cacheService,
@@ -48,7 +52,8 @@ public class ReaderController : BaseApiController
IReaderService readerService, IBookmarkService bookmarkService,
IAccountService accountService, IEventHub eventHub,
IScrobblingService scrobblingService,
- ILocalizationService localizationService)
+ ILocalizationService localizationService,
+ IBookService bookService)
{
_cacheService = cacheService;
_unitOfWork = unitOfWork;
@@ -59,6 +64,7 @@ public class ReaderController : BaseApiController
_eventHub = eventHub;
_scrobblingService = scrobblingService;
_localizationService = localizationService;
+ _bookService = bookService;
}
///
@@ -218,11 +224,10 @@ public class ReaderController : BaseApiController
/// This is generally the first call when attempting to read to allow pre-generation of assets needed for reading
///
/// Should Kavita extract pdf into images. Defaults to false.
- /// Include file dimensions. Only useful for image based reading
+ /// Include file dimensions. Only useful for image-based reading
///
[HttpGet("chapter-info")]
- [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"
- ])]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])]
public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
{
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
@@ -720,26 +725,63 @@ public class ReaderController : BaseApiController
[HttpPost("bookmark")]
public async Task BookmarkPage(BookmarkDto bookmarkDto)
{
- // Don't let user save past total pages.
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
- if (user == null) return new UnauthorizedResult();
+ try
+ {
+ // Don't let user save past total pages.
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
+ AppUserIncludes.Bookmarks);
+ if (user == null) return new UnauthorizedResult();
- if (!await _accountService.HasBookmarkPermission(user))
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
+ if (!await _accountService.HasBookmarkPermission(user))
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
- var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
- if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
+ var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
+ if (chapter == null || chapter.Files.Count == 0)
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
- bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
- var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
+ bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
- if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
+
+ string path;
+ string? chapterTitle;
+ if (Parser.IsEpub(chapter.Files.First().Extension!))
+ {
+ var cachedFilePath = _cacheService.GetCachedFile(chapter);
+ path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath);
+
+
+ var chapterEntity = await _unitOfWork.ChapterRepository.GetChapterAsync(bookmarkDto.ChapterId);
+ if (chapterEntity == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
+ var toc = await _bookService.GenerateTableOfContents(chapterEntity);
+ chapterTitle = BookService.GetChapterTitleFromToC(toc, bookmarkDto.Page);
+ }
+ else
+ {
+ path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
+ chapterTitle = chapter.TitleName;
+ }
+
+ bookmarkDto.ChapterTitle = chapterTitle;
+
+
+
+ if (string.IsNullOrEmpty(path) || !await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
+ {
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
+ }
+
+
+ BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
+ return Ok();
+ }
+ catch (KavitaException ex)
+ {
+ _logger.LogError(ex, "There was an exception when trying to create a bookmark");
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
-
- BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
- return Ok();
+ }
}
+
///
/// Removes a bookmarked page for a Chapter
///
@@ -826,6 +868,48 @@ public class ReaderController : BaseApiController
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
+
+ ///
+ /// For the current user, returns an estimate on how long it would take to finish reading the chapter.
+ ///
+ /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.
+ ///
+ ///
+ ///
+ [HttpGet("time-left-for-chapter")]
+ [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "chapterId"])]
+ public async Task> GetEstimateToCompletionForChapter(int seriesId, int chapterId)
+ {
+ var userId = User.GetUserId();
+ var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
+ if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
+
+ // Patch in the reading progress
+ await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter);
+
+ if (series.Format == MangaFormat.Epub)
+ {
+ // Get the word counts for all the pages
+ var pageCounts = await _bookService.GetWordCountsPerPage(chapter.Files.First().FilePath); // TODO: Cache
+ if (pageCounts == null) return _readerService.GetTimeEstimate(series.WordCount, 0, true);
+
+ // Sum character counts only for pages that have been read
+ var totalCharactersRead = pageCounts
+ .Where(kvp => kvp.Key <= chapter.PagesRead)
+ .Sum(kvp => kvp.Value);
+
+ var progressCount = WordCountAnalyzerService.GetWordCount(totalCharactersRead);
+ var wordsLeft = series.WordCount - progressCount;
+ return _readerService.GetTimeEstimate(wordsLeft, 0, true);
+ }
+
+ var pagesLeft = chapter.Pages - chapter.PagesRead;
+ return _readerService.GetTimeEstimate(0, pagesLeft, false);
+ }
+
+
+
///
/// Returns the user's personal table of contents for the given chapter
///
@@ -879,6 +963,12 @@ public class ReaderController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
}
+ // Look up the chapter this PTOC is associated with to get the chapter title (if there is one)
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
+ if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
+ var toc = await _bookService.GenerateTableOfContents(chapter);
+ var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber);
+
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
{
Title = dto.Title.Trim(),
@@ -887,12 +977,16 @@ public class ReaderController : BaseApiController
SeriesId = dto.SeriesId,
LibraryId = dto.LibraryId,
BookScrollId = dto.BookScrollId,
+ SelectedText = dto.SelectedText,
+ ChapterTitle = chapterTitle,
AppUserId = userId
});
await _unitOfWork.CommitAsync();
return Ok();
}
+
+
///
/// Get all progress events for a given chapter
///
@@ -905,4 +999,5 @@ public class ReaderController : BaseApiController
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId));
}
+
}
diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs
index 2573c8ca8..ae9c7ddd8 100644
--- a/API/Controllers/SeriesController.cs
+++ b/API/Controllers/SeriesController.cs
@@ -185,6 +185,11 @@ public class SeriesController : BaseApiController
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
+ ///
+ /// All chapter entities will load this data by default. Will not be maintained as of v0.8.1
+ ///
+ ///
+ ///
[Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")]
[HttpGet("chapter-metadata")]
public async Task> GetChapterMetadata(int chapterId)
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index 17ebc758e..abf8468f0 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -109,6 +109,7 @@ public class UsersController : BaseApiController
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
+ existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
if (await _licenseService.HasActiveLicense())
{
diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs
new file mode 100644
index 000000000..911e3ab47
--- /dev/null
+++ b/API/DTOs/Reader/AnnotationDto.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using API.Entities;
+using API.Entities.Enums;
+
+namespace API.DTOs.Reader;
+
+///
+/// Represents an annotation on a book
+///
+public sealed record AnnotationDto
+{
+ public int Id { get; set; }
+ ///
+ /// Starting point of the Highlight
+ ///
+ public required string XPath { get; set; }
+ ///
+ /// Ending point of the Highlight. Can be the same as
+ ///
+ public string EndingXPath { get; set; }
+
+ ///
+ /// The text selected.
+ ///
+ public string SelectedText { get; set; }
+ ///
+ /// Rich text Comment
+ ///
+ public string? Comment { get; set; }
+ ///
+ /// Title of the TOC Chapter within Epub (not Chapter Entity)
+ ///
+ public string? ChapterTitle { get; set; }
+ ///
+ /// A calculated selection of the surrounding text. This does not update after creation.
+ ///
+ public string? Context { get; set; }
+
+ ///
+ /// The number of characters selected
+ ///
+ public int HighlightCount { get; set; }
+ public bool ContainsSpoiler { get; set; }
+ public int PageNumber { get; set; }
+
+ ///
+ /// Selected Highlight Slot Index [0-4]
+ ///
+ public int SelectedSlotIndex { get; set; }
+
+ public required int ChapterId { get; set; }
+ public required int VolumeId { get; set; }
+ public required int SeriesId { get; set; }
+ public required int LibraryId { get; set; }
+
+ public required int OwnerUserId { get; set; }
+ public string OwnerUsername { get; set; }
+
+ public DateTime CreatedUtc { get; set; }
+ public DateTime LastModifiedUtc { get; set; }
+}
diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs
index da18fc28e..b62271408 100644
--- a/API/DTOs/Reader/BookmarkDto.cs
+++ b/API/DTOs/Reader/BookmarkDto.cs
@@ -15,7 +15,19 @@ public sealed record BookmarkDto
[Required]
public int ChapterId { get; set; }
///
+ /// Only applicable for Epubs
+ ///
+ public int ImageOffset { get; set; }
+ ///
+ /// Only applicable for Epubs
+ ///
+ public string? XPath { get; set; }
+ ///
/// This is only used when getting all bookmarks.
///
public SeriesDto? Series { get; set; }
+ ///
+ /// Not required, will be filled out at API before saving to the DB
+ ///
+ public string? ChapterTitle { get; set; }
}
diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs
index 95272ca58..545e17e47 100644
--- a/API/DTOs/Reader/CreatePersonalToCDto.cs
+++ b/API/DTOs/Reader/CreatePersonalToCDto.cs
@@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto
public required int PageNumber { get; set; }
public required string Title { get; set; }
public string? BookScrollId { get; set; }
+ public string? SelectedText { get; set; }
}
diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs
index c979d9d78..66994a7ff 100644
--- a/API/DTOs/Reader/PersonalToCDto.cs
+++ b/API/DTOs/Reader/PersonalToCDto.cs
@@ -4,8 +4,27 @@
public sealed record PersonalToCDto
{
+ public required int Id { get; init; }
public required int ChapterId { get; set; }
+ ///
+ /// The page to bookmark
+ ///
public required int PageNumber { get; set; }
+ ///
+ /// The title of the bookmark. Defaults to Page {PageNumber} if not set
+ ///
public required string Title { get; set; }
+ ///
+ /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page
+ ///
public string? BookScrollId { get; set; }
+ ///
+ /// Text of the bookmark
+ ///
+ public string? SelectedText { get; set; }
+ ///
+ /// Title of the Chapter this PToC was created in
+ ///
+ /// Taken from the ToC
+ public string? ChapterTitle { get; set; }
}
diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs
index 11c4bdc08..ba385b553 100644
--- a/API/DTOs/Search/SearchResultGroupDto.cs
+++ b/API/DTOs/Search/SearchResultGroupDto.cs
@@ -23,6 +23,7 @@ public sealed record SearchResultGroupDto
public IEnumerable Files { get; set; } = default!;
public IEnumerable Chapters { get; set; } = default!;
public IEnumerable Bookmarks { get; set; } = default!;
+ public IEnumerable Annotations { get; set; } = default!;
}
diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs
index 46f42306e..b7fd625f4 100644
--- a/API/DTOs/UserPreferencesDto.cs
+++ b/API/DTOs/UserPreferencesDto.cs
@@ -1,4 +1,5 @@
-using System.ComponentModel.DataAnnotations;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
@@ -41,4 +42,7 @@ public sealed record UserPreferencesDto
public bool AniListScrobblingEnabled { get; set; }
///
public bool WantToReadSync { get; set; }
+ ///
+ [Required]
+ public List BookReaderHighlightSlots { get; set; }
}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index b558d4989..a455cc32f 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext MetadataFieldMapping { get; set; } = null!;
public DbSet AppUserChapterRating { get; set; } = null!;
public DbSet AppUserReadingProfiles { get; set; } = null!;
+ public DbSet AppUserAnnotation { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -301,6 +302,14 @@ public sealed class DataContext : IdentityDbContext());
+ builder.Entity()
+ .Property(a => a.BookReaderHighlightSlots)
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
+ v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List())
+ .HasColumnType("TEXT")
+ .HasDefaultValue(new List());
+
builder.Entity()
.Property(user => user.IdentityProvider)
.HasDefaultValue(IdentityProvider.Kavita);
diff --git a/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs b/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs
new file mode 100644
index 000000000..3a5b14539
--- /dev/null
+++ b/API/Data/ManualMigrations/v0.8.8/ManualMigrateBookReadingProgress.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Entities.History;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+///
+/// v0.8.8 - Switch existing xpaths saved to a descoped version
+///
+public static class ManualMigrateBookReadingProgress
+{
+ ///
+ /// Scope from 2023 era before a DOM change
+ ///
+ private const string OldScope = "//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/";
+ ///
+ /// Scope from post DOM change
+ ///
+ private const string NewScope = "//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[3]/";
+ ///
+ /// New-descoped prefix
+ ///
+ private const string ReplacementScope = "//BODY/DIV[1]";
+
+ public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger logger)
+ {
+
+ if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateBookReadingProgress"))
+ {
+ return;
+ }
+
+
+
+ logger.LogCritical("Running ManualMigrateBookReadingProgress migration - Please be patient, this may take some time. This is not an error");
+
+ var bookProgress = await context.AppUserProgresses
+ .Where(p => p.BookScrollId != null && (p.BookScrollId.StartsWith(OldScope) || p.BookScrollId.StartsWith(NewScope)))
+ .ToListAsync();
+
+
+ foreach (var progress in bookProgress)
+ {
+ if (string.IsNullOrEmpty(progress.BookScrollId)) continue;
+
+ if (progress.BookScrollId.StartsWith(OldScope))
+ {
+ progress.BookScrollId = progress.BookScrollId.Replace(OldScope, ReplacementScope);
+ context.AppUserProgresses.Update(progress);
+ } else if (progress.BookScrollId.StartsWith(NewScope))
+ {
+ progress.BookScrollId = progress.BookScrollId.Replace(NewScope, ReplacementScope);
+ context.AppUserProgresses.Update(progress);
+ }
+ }
+
+ if (unitOfWork.HasChanges())
+ {
+ await context.SaveChangesAsync();
+ }
+
+ var ptocEntries = await context.AppUserTableOfContent
+ .Where(p => p.BookScrollId != null && (p.BookScrollId.StartsWith(OldScope) || p.BookScrollId.StartsWith(NewScope)))
+ .ToListAsync();
+
+ foreach (var ptoc in ptocEntries)
+ {
+ if (string.IsNullOrEmpty(ptoc.BookScrollId)) continue;
+
+ if (ptoc.BookScrollId.StartsWith(OldScope))
+ {
+ ptoc.BookScrollId = ptoc.BookScrollId.Replace(OldScope, ReplacementScope);
+ context.AppUserTableOfContent.Update(ptoc);
+ } else if (ptoc.BookScrollId.StartsWith(NewScope))
+ {
+ ptoc.BookScrollId = ptoc.BookScrollId.Replace(NewScope, ReplacementScope);
+ context.AppUserTableOfContent.Update(ptoc);
+ }
+ }
+
+ if (unitOfWork.HasChanges())
+ {
+ await context.SaveChangesAsync();
+ }
+
+ await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
+ {
+ Name = "ManualMigrateBookReadingProgress",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow
+ });
+ await context.SaveChangesAsync();
+
+ logger.LogCritical("Running ManualMigrateBookReadingProgress migration - Completed. This is not an error");
+ }
+}
diff --git a/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs b/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs
new file mode 100644
index 000000000..a8822d149
--- /dev/null
+++ b/API/Data/Migrations/20250820150458_BookAnnotations.Designer.cs
@@ -0,0 +1,3849 @@
+//
+using System;
+using System.Collections.Generic;
+using API.Data;
+using API.Entities.MetadataMatching;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace API.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20250820150458_BookAnnotations")]
+ partial class BookAnnotations
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasRunScrobbleEventGeneration")
+ .HasColumnType("INTEGER");
+
+ b.Property("IdentityProvider")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("MalAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("MalUserName")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("OidcId")
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ScrobbleEventGenerationRan")
+ .HasColumnType("TEXT");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("ContainsSpoiler")
+ .HasColumnType("INTEGER");
+
+ b.Property("Context")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EndingXPath")
+ .HasColumnType("TEXT");
+
+ b.Property("HighlightCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedSlotIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedText")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.ToTable("AppUserAnnotation");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageOffset")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserChapterRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserCollection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSeriesFromSource")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Source")
+ .HasColumnType("INTEGER");
+
+ b.Property("SourceUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalSourceCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserCollection");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(4);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserDashboardStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserExternalSource");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowAutomaticWebtoonReaderDetection")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AniListScrobblingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderHighlightSlots")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("[]");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("en");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("WantToReadSync")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserProgresses");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowAutomaticWebtoonReaderDetection")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("DisableWidthOverride")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryIds")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesIds")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("WidthOverride")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserReadingProfiles");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property