Added a poc marker for where a bookmark resides.

Added a marker where a highlight might reside too.

Can move forward with a proper implementation.
This commit is contained in:
Joseph Milazzo 2025-06-29 16:45:44 -05:00
parent e5d949161e
commit 32ee60e1de
14 changed files with 257 additions and 76 deletions

View file

@ -157,7 +157,10 @@ public class BookController : BaseApiController
try try
{ {
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); var ptocBookmarks =
await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks));
} }
catch (KavitaException ex) catch (KavitaException ex)
{ {

View file

@ -879,6 +879,7 @@ public class ReaderController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark")); return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
} }
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
{ {
Title = dto.Title.Trim(), Title = dto.Title.Trim(),
@ -887,6 +888,7 @@ public class ReaderController : BaseApiController
SeriesId = dto.SeriesId, SeriesId = dto.SeriesId,
LibraryId = dto.LibraryId, LibraryId = dto.LibraryId,
BookScrollId = dto.BookScrollId, BookScrollId = dto.BookScrollId,
SelectedText = dto.SelectedText,
AppUserId = userId AppUserId = userId
}); });
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();

View file

@ -10,4 +10,5 @@ public sealed record CreatePersonalToCDto
public required int PageNumber { get; set; } public required int PageNumber { get; set; }
public required string Title { get; set; } public required string Title { get; set; }
public string? BookScrollId { get; set; } public string? BookScrollId { get; set; }
public string? SelectedText { get; set; }
} }

View file

@ -4,6 +4,7 @@
public sealed record PersonalToCDto public sealed record PersonalToCDto
{ {
public required int Id { get; init; }
public required int ChapterId { get; set; } public required int ChapterId { get; set; }
public required int PageNumber { get; set; } public required int PageNumber { get; set; }
public required string Title { get; set; } public required string Title { get; set; }

View file

@ -1,8 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using API.Data; using API.Data;
using API.Entities.MetadataMatching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -194,7 +192,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark"); b.ToTable("AppUserBookmark", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserChapterRating", b => modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
@ -229,7 +227,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserChapterRating"); b.ToTable("AppUserChapterRating", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserCollection", b => modelBuilder.Entity("API.Entities.AppUserCollection", b =>
@ -301,7 +299,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserCollection"); b.ToTable("AppUserCollection", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
@ -341,7 +339,7 @@ namespace API.Data.Migrations
b.HasIndex("Visible"); b.HasIndex("Visible");
b.ToTable("AppUserDashboardStream"); b.ToTable("AppUserDashboardStream", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserExternalSource", b => modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
@ -366,7 +364,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserExternalSource"); b.ToTable("AppUserExternalSource", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
@ -387,7 +385,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserOnDeckRemoval"); b.ToTable("AppUserOnDeckRemoval", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserPreferences", b => modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -525,7 +523,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId"); b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences"); b.ToTable("AppUserPreferences", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserProgress", b => modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -575,7 +573,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses"); b.ToTable("AppUserProgresses", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRating", b => modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -608,7 +606,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserRating"); b.ToTable("AppUserRating", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
@ -725,7 +723,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserReadingProfiles"); b.ToTable("AppUserReadingProfiles", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRole", b => modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -786,7 +784,7 @@ namespace API.Data.Migrations
b.HasIndex("Visible"); b.HasIndex("Visible");
b.ToTable("AppUserSideNavStream"); b.ToTable("AppUserSideNavStream", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
@ -808,7 +806,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserSmartFilter"); b.ToTable("AppUserSmartFilter", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
@ -861,7 +859,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserTableOfContent"); b.ToTable("AppUserTableOfContent", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserWantToRead", b => modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
@ -882,7 +880,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserWantToRead"); b.ToTable("AppUserWantToRead", (string)null);
}); });
modelBuilder.Entity("API.Entities.Chapter", b => modelBuilder.Entity("API.Entities.Chapter", b =>
@ -1081,7 +1079,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("Chapter"); b.ToTable("Chapter", (string)null);
}); });
modelBuilder.Entity("API.Entities.CollectionTag", b => modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -1116,7 +1114,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted") b.HasIndex("Id", "Promoted")
.IsUnique(); .IsUnique();
b.ToTable("CollectionTag"); b.ToTable("CollectionTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Device", b => modelBuilder.Entity("API.Entities.Device", b =>
@ -1162,7 +1160,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("Device"); b.ToTable("Device", (string)null);
}); });
modelBuilder.Entity("API.Entities.EmailHistory", b => modelBuilder.Entity("API.Entities.EmailHistory", b =>
@ -1213,7 +1211,7 @@ namespace API.Data.Migrations
b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate");
b.ToTable("EmailHistory"); b.ToTable("EmailHistory", (string)null);
}); });
modelBuilder.Entity("API.Entities.FolderPath", b => modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -1235,7 +1233,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("FolderPath"); b.ToTable("FolderPath", (string)null);
}); });
modelBuilder.Entity("API.Entities.Genre", b => modelBuilder.Entity("API.Entities.Genre", b =>
@ -1255,7 +1253,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Genre"); b.ToTable("Genre", (string)null);
}); });
modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b =>
@ -1275,7 +1273,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ManualMigrationHistory"); b.ToTable("ManualMigrationHistory", (string)null);
}); });
modelBuilder.Entity("API.Entities.Library", b => modelBuilder.Entity("API.Entities.Library", b =>
@ -1349,7 +1347,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Library"); b.ToTable("Library", (string)null);
}); });
modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => modelBuilder.Entity("API.Entities.LibraryExcludePattern", b =>
@ -1368,7 +1366,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("LibraryExcludePattern"); b.ToTable("LibraryExcludePattern", (string)null);
}); });
modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b =>
@ -1387,7 +1385,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("LibraryFileTypeGroup"); b.ToTable("LibraryFileTypeGroup", (string)null);
}); });
modelBuilder.Entity("API.Entities.MangaFile", b => modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -1442,7 +1440,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId"); b.HasIndex("ChapterId");
b.ToTable("MangaFile"); b.ToTable("MangaFile", (string)null);
}); });
modelBuilder.Entity("API.Entities.MediaError", b => modelBuilder.Entity("API.Entities.MediaError", b =>
@ -1477,7 +1475,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("MediaError"); b.ToTable("MediaError", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
@ -1511,7 +1509,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId"); b.HasIndex("ChapterId");
b.ToTable("ExternalRating"); b.ToTable("ExternalRating", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
@ -1548,7 +1546,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("ExternalRecommendation"); b.ToTable("ExternalRecommendation", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
@ -1600,7 +1598,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId"); b.HasIndex("ChapterId");
b.ToTable("ExternalReview"); b.ToTable("ExternalReview", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
@ -1635,7 +1633,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId") b.HasIndex("SeriesId")
.IsUnique(); .IsUnique();
b.ToTable("ExternalSeriesMetadata"); b.ToTable("ExternalSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
@ -1654,7 +1652,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("SeriesBlacklist"); b.ToTable("SeriesBlacklist", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -1769,7 +1767,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId") b.HasIndex("Id", "SeriesId")
.IsUnique(); .IsUnique();
b.ToTable("SeriesMetadata"); b.ToTable("SeriesMetadata", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -1793,7 +1791,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId"); b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation"); b.ToTable("SeriesRelation", (string)null);
}); });
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
@ -1824,7 +1822,7 @@ namespace API.Data.Migrations
b.HasIndex("MetadataSettingsId"); b.HasIndex("MetadataSettingsId");
b.ToTable("MetadataFieldMapping"); b.ToTable("MetadataFieldMapping", (string)null);
}); });
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
@ -1902,7 +1900,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("MetadataSettings"); b.ToTable("MetadataSettings", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
@ -1926,7 +1924,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId"); b.HasIndex("PersonId");
b.ToTable("ChapterPeople"); b.ToTable("ChapterPeople", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person.Person", b => modelBuilder.Entity("API.Entities.Person.Person", b =>
@ -1970,7 +1968,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Person"); b.ToTable("Person", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person.PersonAlias", b => modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
@ -1992,7 +1990,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId"); b.HasIndex("PersonId");
b.ToTable("PersonAlias"); b.ToTable("PersonAlias", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
@ -2018,7 +2016,7 @@ namespace API.Data.Migrations
b.HasIndex("PersonId"); b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople"); b.ToTable("SeriesMetadataPeople", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingList", b => modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -2087,7 +2085,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("ReadingList"); b.ToTable("ReadingList", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingListItem", b => modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -2121,7 +2119,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("ReadingListItem"); b.ToTable("ReadingListItem", (string)null);
}); });
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
@ -2166,7 +2164,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("ScrobbleError"); b.ToTable("ScrobbleError", (string)null);
}); });
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
@ -2243,7 +2241,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("ScrobbleEvent"); b.ToTable("ScrobbleEvent", (string)null);
}); });
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
@ -2276,7 +2274,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("ScrobbleHold"); b.ToTable("ScrobbleHold", (string)null);
}); });
modelBuilder.Entity("API.Entities.Series", b => modelBuilder.Entity("API.Entities.Series", b =>
@ -2382,7 +2380,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("Series"); b.ToTable("Series", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerSetting", b => modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -2399,7 +2397,7 @@ namespace API.Data.Migrations
b.HasKey("Key"); b.HasKey("Key");
b.ToTable("ServerSetting"); b.ToTable("ServerSetting", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerStatistics", b => modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -2437,7 +2435,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ServerStatistics"); b.ToTable("ServerStatistics", (string)null);
}); });
modelBuilder.Entity("API.Entities.SiteTheme", b => modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -2493,7 +2491,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("SiteTheme"); b.ToTable("SiteTheme", (string)null);
}); });
modelBuilder.Entity("API.Entities.Tag", b => modelBuilder.Entity("API.Entities.Tag", b =>
@ -2513,7 +2511,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Tag"); b.ToTable("Tag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Volume", b => modelBuilder.Entity("API.Entities.Volume", b =>
@ -2583,7 +2581,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("Volume"); b.ToTable("Volume", (string)null);
}); });
modelBuilder.Entity("AppUserCollectionSeries", b => modelBuilder.Entity("AppUserCollectionSeries", b =>
@ -2598,7 +2596,7 @@ namespace API.Data.Migrations
b.HasIndex("ItemsId"); b.HasIndex("ItemsId");
b.ToTable("AppUserCollectionSeries"); b.ToTable("AppUserCollectionSeries", (string)null);
}); });
modelBuilder.Entity("AppUserLibrary", b => modelBuilder.Entity("AppUserLibrary", b =>
@ -2613,7 +2611,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId"); b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary"); b.ToTable("AppUserLibrary", (string)null);
}); });
modelBuilder.Entity("ChapterGenre", b => modelBuilder.Entity("ChapterGenre", b =>
@ -2628,7 +2626,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId"); b.HasIndex("GenresId");
b.ToTable("ChapterGenre"); b.ToTable("ChapterGenre", (string)null);
}); });
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
@ -2643,7 +2641,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("ChapterTag"); b.ToTable("ChapterTag", (string)null);
}); });
modelBuilder.Entity("CollectionTagSeriesMetadata", b => modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -2658,7 +2656,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata"); b.ToTable("CollectionTagSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
@ -2673,7 +2671,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId"); b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRatingExternalSeriesMetadata"); b.ToTable("ExternalRatingExternalSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
@ -2688,7 +2686,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId"); b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalRecommendationExternalSeriesMetadata"); b.ToTable("ExternalRecommendationExternalSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
@ -2703,7 +2701,7 @@ namespace API.Data.Migrations
b.HasIndex("ExternalSeriesMetadatasId"); b.HasIndex("ExternalSeriesMetadatasId");
b.ToTable("ExternalReviewExternalSeriesMetadata"); b.ToTable("ExternalReviewExternalSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("GenreSeriesMetadata", b => modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -2718,7 +2716,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata"); b.ToTable("GenreSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -2817,7 +2815,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag"); b.ToTable("SeriesMetadataTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserBookmark", b => modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View file

@ -16,6 +16,7 @@ public interface IUserTableOfContentRepository
void Remove(AppUserTableOfContent toc); void Remove(AppUserTableOfContent toc);
Task<bool> IsUnique(int userId, int chapterId, int page, string title); Task<bool> IsUnique(int userId, int chapterId, int page, string title);
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId); IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page);
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title); Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
} }
@ -55,6 +56,15 @@ public class UserTableOfContentRepository : IUserTableOfContentRepository
.AsEnumerable(); .AsEnumerable();
} }
public async Task<List<PersonalToCDto>> GetPersonalToCForPage(int userId, int chapterId, int page)
{
return await _context.AppUserTableOfContent
.Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == page)
.ProjectTo<PersonalToCDto>(_mapper.ConfigurationProvider)
.OrderBy(t => t.PageNumber)
.ToListAsync();
}
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title) public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
{ {
return await _context.AppUserTableOfContent return await _context.AppUserTableOfContent

View file

@ -31,6 +31,10 @@ public class AppUserTableOfContent : IEntityDate
/// 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 /// 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
/// </summary> /// </summary>
public string? BookScrollId { get; set; } public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
public DateTime Created { get; set; } public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; } public DateTime CreatedUtc { get; set; }

View file

@ -57,7 +57,7 @@ public interface IBookService
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param> /// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory); void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter); Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book); Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
} }
@ -321,6 +321,71 @@ public class BookService : IBookService
} }
} }
/// <summary>
/// For each bookmark on this page, inject a specialized icon
/// </summary>
/// <param name="doc"></param>
/// <param name="book"></param>
/// <param name="ptocBookmarks"></param>
private static void InjectPTOCBookmarks(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> ptocBookmarks)
{
if (ptocBookmarks.Count == 0) return;
foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
{
var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
elem?.PrependChild(HtmlNode.CreateNode($"<i class='fa-solid fa-bookmark ps-1 pe-1' role='button' id='ptoc-{bookmark.Id}' title='{bookmark.Title}'></i>"));
}
}
private static void InjectHighlights(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> ptocBookmarks)
{
if (ptocBookmarks.Count == 0) return;
foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
{
var unscopedSelector = bookmark.BookScrollId.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
if (elem == null) continue;
// For this POC, assume we have 16 characters highlighted and those characters are: "For the past few"
// Get the original text content
var originalText = elem.InnerText;
// For POC: highlight first 16 characters
const int highlightLength = 16;
if (originalText.Length > highlightLength)
{
var highlightedText = originalText.Substring(0, highlightLength);
var remainingText = originalText.Substring(highlightLength);
// Clear the existing content
elem.RemoveAllChildren();
// Create the highlight element with the first 16 characters
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{highlightedText}</app-epub-highlight>");
elem.AppendChild(highlightNode);
// Add the remaining text as a text node
var remainingTextNode = HtmlNode.CreateNode(remainingText);
elem.AppendChild(remainingTextNode);
}
else
{
// If text is shorter than highlight length, wrap it all
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{originalText}</app-epub-highlight>");
elem.RemoveAllChildren();
elem.AppendChild(highlightNode);
}
}
}
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
{ {
var images = doc.DocumentNode.SelectNodes("//img") var images = doc.DocumentNode.SelectNodes("//img")
@ -1016,8 +1081,9 @@ public class BookService : IBookService
/// <param name="body">Body element from the epub</param> /// <param name="body">Body element from the epub</param>
/// <param name="mappings">Epub mappings</param> /// <param name="mappings">Epub mappings</param>
/// <param name="page">Page number we are loading</param> /// <param name="page">Page number we are loading</param>
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
/// <returns></returns> /// <returns></returns>
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page) private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks)
{ {
await InlineStyles(doc, book, apiBase, body); await InlineStyles(doc, book, apiBase, body);
@ -1025,6 +1091,13 @@ public class BookService : IBookService
ScopeImages(doc, book, apiBase); ScopeImages(doc, book, apiBase);
// Inject PTOC Bookmark Icons
InjectPTOCBookmarks(doc, book, ptocBookmarks);
// MOCK: This will mimic a highlight
InjectHighlights(doc, book, ptocBookmarks);
return PrepareFinalHtml(doc, body); return PrepareFinalHtml(doc, body);
} }
@ -1215,7 +1288,7 @@ public class BookService : IBookService
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param> /// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns> /// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
/// <exception cref="KavitaException">All exceptions throw this</exception> /// <exception cref="KavitaException">All exceptions throw this</exception>
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks)
{ {
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book); var mappings = await CreateKeyToPageMappingAsync(book);
@ -1257,7 +1330,7 @@ public class BookService : IBookService
body = doc.DocumentNode.SelectSingleNode("/html/body"); body = doc.DocumentNode.SelectSingleNode("/html/body");
} }
return await ScopePage(doc, book, apiBase, body, mappings, page); return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks);
} }
} catch (Exception ex) } catch (Exception ex)
{ {

View file

@ -326,8 +326,8 @@ export class ReaderService {
return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId); return this.httpClient.get<Array<PersonalToC>>(this.baseUrl + 'reader/ptoc?chapterId=' + chapterId);
} }
createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null) { createPersonalToC(libraryId: number, seriesId: number, volumeId: number, chapterId: number, pageNumber: number, title: string, bookScrollId: string | null, selectedText: string) {
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId}); return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText});
} }
getElementFromXPath(path: string) { getElementFromXPath(path: string) {

View file

@ -1,11 +1,15 @@
import { import {
ChangeDetectionStrategy, ChangeDetectorRef, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
ElementRef, EventEmitter, HostListener, ElementRef,
EventEmitter,
HostListener,
inject, inject,
Input, Input,
OnInit, Output, OnInit,
Output,
} from '@angular/core'; } from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {fromEvent, merge, of} from "rxjs"; import {fromEvent, merge, of} from "rxjs";
@ -125,7 +129,7 @@ export class BookLineOverlayComponent implements OnInit {
createPTOC() { createPTOC() {
this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.readerService.createPersonalToC(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber,
this.bookmarkForm.get('name')?.value, this.xPath).pipe(catchError(err => { this.bookmarkForm.get('name')?.value, this.xPath, this.selectedText).pipe(catchError(err => {
this.focusOnBookmarkInput(); this.focusOnBookmarkInput();
return of(); return of();
})).subscribe(() => { })).subscribe(() => {

View file

@ -13,7 +13,8 @@ import {
OnInit, OnInit,
Renderer2, Renderer2,
RendererStyleFlags2, RendererStyleFlags2,
ViewChild ViewChild,
ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common'; import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
@ -63,6 +64,7 @@ import {
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
import {ConfirmService} from "../../../shared/confirm.service"; import {ConfirmService} from "../../../shared/confirm.service";
import {EpubHighlightComponent} from "../epub-highlight/epub-highlight.component";
enum TabID { enum TabID {
@ -351,6 +353,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>; @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('reader', {static: false}) reader!: ElementRef; @ViewChild('reader', {static: false}) reader!: ElementRef;
@ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef;
/** /**
* Disables the Left most button * Disables the Left most button
@ -543,7 +546,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}) })
) )
.subscribe(); .subscribe();
} }
handleScrollEvent() { handleScrollEvent() {
@ -1093,6 +1095,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.saveProgress(); this.saveProgress();
this.isLoading = false; this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
// Make the highlight components "real"
const highlightElems = this.document.querySelectorAll('app-epub-highlight');
for (let i = 0; i < highlightElems.length; i++) {
const highlight = highlightElems[i];
const componentRef = this.readingContainer.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
{projectableNodes: [[document.createTextNode(highlight.innerHTML)]]});
if (highlight.parentNode != null) {
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
}
//componentRef.instance.cdRef.markForCheck();
}
} }
private addEmptyPageIfRequired(): void { private addEmptyPageIfRequired(): void {
@ -1316,7 +1333,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
getFirstVisibleElementXPath() { getFirstVisibleElementXPath() {
let resumeElement: string | null = null; let resumeElement: string | null = null;
if (this.bookContentElemRef === null) return null; if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null;
const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span'))
.filter(element => !element.classList.contains('no-observe')) .filter(element => !element.classList.contains('no-observe'))

View file

@ -0,0 +1,6 @@
<span
[class]="highlightClasses()"
[attr.data-highlight-color]="color()">
<i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>
<ng-content></ng-content>
</span>

View file

@ -0,0 +1,32 @@
.epub-highlight {
position: relative;
transition: all 0.2s ease-in-out;
}
.epub-highlight-blue {
background-color: rgba(59, 130, 246, 0.3);
border-radius: 3px;
padding: 1px 2px;
}
.epub-highlight-green {
background-color: rgba(34, 197, 94, 0.3);
border-radius: 3px;
padding: 1px 2px;
}
.epub-highlight-blue:hover {
background-color: rgba(59, 130, 246, 0.4);
}
.epub-highlight-green:hover {
background-color: rgba(34, 197, 94, 0.4);
}
.epub-highlight-blue {
border-bottom: 1px solid rgba(59, 130, 246, 0.5);
}
.epub-highlight-green {
border-bottom: 1px solid rgba(34, 197, 94, 0.5);
}

View file

@ -0,0 +1,30 @@
import {Component, computed, input, model} from '@angular/core';
export type HighlightColor = 'blue' | 'green';
@Component({
selector: 'app-epub-highlight',
imports: [],
templateUrl: './epub-highlight.component.html',
styleUrl: './epub-highlight.component.scss'
})
export class EpubHighlightComponent {
showHighlight = model<boolean>(false);
color = input<HighlightColor>('blue');
highlightClasses = computed(() => {
const baseClass = 'epub-highlight';
if (!this.showHighlight()) {
return baseClass;
}
const colorClass = `epub-highlight-${this.color()}`;
return `${baseClass} ${colorClass}`;
});
toggleHighlight() {
this.showHighlight.set(!this.showHighlight());
}
}