Started the refactoring so that there is a menu service to handle opening/closing the different drawers.
This commit is contained in:
parent
fc54f8571f
commit
4d4b3c7285
41 changed files with 4666 additions and 142 deletions
|
|
@ -159,8 +159,9 @@ public class BookController : BaseApiController
|
|||
{
|
||||
var ptocBookmarks =
|
||||
await _unitOfWork.UserTableOfContentRepository.GetPersonalToCForPage(User.GetUserId(), chapterId, page);
|
||||
var annotations = await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapter.Id);
|
||||
|
||||
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks));
|
||||
return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl, ptocBookmarks, annotations));
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -826,6 +826,18 @@ public class ReaderController : BaseApiController
|
|||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the annotations for the given chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("annotations")]
|
||||
public async Task<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
|
||||
{
|
||||
|
||||
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the user's personal table of contents for the given chapter
|
||||
/// </summary>
|
||||
|
|
|
|||
46
API/DTOs/Reader/AnnotationDto.cs
Normal file
46
API/DTOs/Reader/AnnotationDto.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using System;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Reader;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an annotation on a book
|
||||
/// </summary>
|
||||
public sealed record AnnotationDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Starting point of the Highlight
|
||||
/// </summary>
|
||||
public required string XPath { get; set; }
|
||||
/// <summary>
|
||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
||||
/// </summary>
|
||||
public string EndingXPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The text selected.
|
||||
/// </summary>
|
||||
public string SelectedText { get; set; }
|
||||
/// <summary>
|
||||
/// Rich text Comment
|
||||
/// </summary>
|
||||
public string? Comment { get; set; }
|
||||
/// <summary>
|
||||
/// The number of characters selected
|
||||
/// </summary>
|
||||
public int HighlightCount { get; set; }
|
||||
public bool ContainsSpoiler { get; set; }
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
public HightlightColor HighlightColor { get; set; }
|
||||
|
||||
public required int ChapterId { get; set; }
|
||||
|
||||
public required int OwnerUserId { get; set; }
|
||||
public string OwnerUsername { get; set; }
|
||||
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
}
|
||||
35
API/DTOs/Reader/CreateAnnotationRequest.cs
Normal file
35
API/DTOs/Reader/CreateAnnotationRequest.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Reader;
|
||||
|
||||
public sealed record CreateAnnotationRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Starting point of the Highlight
|
||||
/// </summary>
|
||||
public required string XPath { get; set; }
|
||||
/// <summary>
|
||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
||||
/// </summary>
|
||||
public string EndingXPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The text selected.
|
||||
/// </summary>
|
||||
public string SelectedText { get; set; }
|
||||
/// <summary>
|
||||
/// Rich text Comment
|
||||
/// </summary>
|
||||
public string? Comment { get; set; }
|
||||
/// <summary>
|
||||
/// The number of characters selected
|
||||
/// </summary>
|
||||
public int HighlightCount { get; set; }
|
||||
public bool ContainsSpoiler { get; set; }
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
public HightlightColor HighlightColor { get; set; }
|
||||
|
||||
public required int ChapterId { get; set; }
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
||||
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
|
|
|||
3808
API/Data/Migrations/20250704153900_BookAnnotations.Designer.cs
generated
Normal file
3808
API/Data/Migrations/20250704153900_BookAnnotations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
82
API/Data/Migrations/20250704153900_BookAnnotations.cs
Normal file
82
API/Data/Migrations/20250704153900_BookAnnotations.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BookAnnotations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SelectedText",
|
||||
table: "AppUserTableOfContent",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserAnnotation",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
XPath = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EndingXPath = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SelectedText = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
||||
HighlightCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PageNumber = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
HighlightColor = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ContainsSpoiler = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserAnnotation", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserAnnotation_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserAnnotation_Chapter_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapter",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserAnnotation_AppUserId",
|
||||
table: "AppUserAnnotation",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserAnnotation_ChapterId",
|
||||
table: "AppUserAnnotation",
|
||||
column: "ChapterId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserAnnotation");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SelectedText",
|
||||
table: "AppUserTableOfContent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Data;
|
||||
using API.Entities.MetadataMatching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
|
@ -152,6 +154,69 @@ namespace API.Data.Migrations
|
|||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ContainsSpoiler")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EndingXPath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HighlightColor")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HighlightCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModifiedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PageNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SelectedText")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VolumeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("XPath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("AppUserAnnotation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -192,7 +257,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserBookmark", (string)null);
|
||||
b.ToTable("AppUserBookmark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
|
||||
|
|
@ -227,7 +292,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserChapterRating", (string)null);
|
||||
b.ToTable("AppUserChapterRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
|
||||
|
|
@ -299,7 +364,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserCollection", (string)null);
|
||||
b.ToTable("AppUserCollection");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
||||
|
|
@ -339,7 +404,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("Visible");
|
||||
|
||||
b.ToTable("AppUserDashboardStream", (string)null);
|
||||
b.ToTable("AppUserDashboardStream");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
|
||||
|
|
@ -364,7 +429,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserExternalSource", (string)null);
|
||||
b.ToTable("AppUserExternalSource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||
|
|
@ -385,7 +450,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
||||
b.ToTable("AppUserOnDeckRemoval");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
|
|
@ -523,7 +588,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences", (string)null);
|
||||
b.ToTable("AppUserPreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
|
|
@ -573,7 +638,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserProgresses", (string)null);
|
||||
b.ToTable("AppUserProgresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||
|
|
@ -606,7 +671,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserRating", (string)null);
|
||||
b.ToTable("AppUserRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
|
||||
|
|
@ -723,7 +788,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserReadingProfiles", (string)null);
|
||||
b.ToTable("AppUserReadingProfiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
|
|
@ -784,7 +849,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("Visible");
|
||||
|
||||
b.ToTable("AppUserSideNavStream", (string)null);
|
||||
b.ToTable("AppUserSideNavStream");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
|
||||
|
|
@ -806,7 +871,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserSmartFilter", (string)null);
|
||||
b.ToTable("AppUserSmartFilter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
|
|
@ -842,6 +907,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("PageNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SelectedText")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
@ -859,7 +927,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserTableOfContent", (string)null);
|
||||
b.ToTable("AppUserTableOfContent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
|
||||
|
|
@ -880,7 +948,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserWantToRead", (string)null);
|
||||
b.ToTable("AppUserWantToRead");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
|
|
@ -1079,7 +1147,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapter", (string)null);
|
||||
b.ToTable("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||
|
|
@ -1114,7 +1182,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("Id", "Promoted")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CollectionTag", (string)null);
|
||||
b.ToTable("CollectionTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
|
|
@ -1160,7 +1228,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("Device", (string)null);
|
||||
b.ToTable("Device");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.EmailHistory", b =>
|
||||
|
|
@ -1211,7 +1279,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate");
|
||||
|
||||
b.ToTable("EmailHistory", (string)null);
|
||||
b.ToTable("EmailHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
|
|
@ -1233,7 +1301,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("FolderPath", (string)null);
|
||||
b.ToTable("FolderPath");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||
|
|
@ -1253,7 +1321,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Genre", (string)null);
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b =>
|
||||
|
|
@ -1273,7 +1341,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ManualMigrationHistory", (string)null);
|
||||
b.ToTable("ManualMigrationHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Library", b =>
|
||||
|
|
@ -1347,7 +1415,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Library", (string)null);
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.LibraryExcludePattern", b =>
|
||||
|
|
@ -1366,7 +1434,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("LibraryExcludePattern", (string)null);
|
||||
b.ToTable("LibraryExcludePattern");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b =>
|
||||
|
|
@ -1385,7 +1453,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("LibraryFileTypeGroup", (string)null);
|
||||
b.ToTable("LibraryFileTypeGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||
|
|
@ -1440,7 +1508,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("MangaFile", (string)null);
|
||||
b.ToTable("MangaFile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
|
|
@ -1475,7 +1543,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MediaError", (string)null);
|
||||
b.ToTable("MediaError");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
|
||||
|
|
@ -1509,7 +1577,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("ExternalRating", (string)null);
|
||||
b.ToTable("ExternalRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
|
||||
|
|
@ -1546,7 +1614,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ExternalRecommendation", (string)null);
|
||||
b.ToTable("ExternalRecommendation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
|
||||
|
|
@ -1598,7 +1666,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("ExternalReview", (string)null);
|
||||
b.ToTable("ExternalReview");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
||||
|
|
@ -1633,7 +1701,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExternalSeriesMetadata", (string)null);
|
||||
b.ToTable("ExternalSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b =>
|
||||
|
|
@ -1652,7 +1720,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBlacklist", (string)null);
|
||||
b.ToTable("SeriesBlacklist");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||
|
|
@ -1767,7 +1835,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("Id", "SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SeriesMetadata", (string)null);
|
||||
b.ToTable("SeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||
|
|
@ -1791,7 +1859,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TargetSeriesId");
|
||||
|
||||
b.ToTable("SeriesRelation", (string)null);
|
||||
b.ToTable("SeriesRelation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
|
||||
|
|
@ -1822,7 +1890,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("MetadataSettingsId");
|
||||
|
||||
b.ToTable("MetadataFieldMapping", (string)null);
|
||||
b.ToTable("MetadataFieldMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
|
||||
|
|
@ -1900,7 +1968,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MetadataSettings", (string)null);
|
||||
b.ToTable("MetadataSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
|
||||
|
|
@ -1924,7 +1992,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("ChapterPeople", (string)null);
|
||||
b.ToTable("ChapterPeople");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.Person", b =>
|
||||
|
|
@ -1968,7 +2036,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Person", (string)null);
|
||||
b.ToTable("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
|
||||
|
|
@ -1990,7 +2058,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("PersonAlias", (string)null);
|
||||
b.ToTable("PersonAlias");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
|
||||
|
|
@ -2016,7 +2084,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("SeriesMetadataPeople", (string)null);
|
||||
b.ToTable("SeriesMetadataPeople");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||
|
|
@ -2085,7 +2153,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("ReadingList", (string)null);
|
||||
b.ToTable("ReadingList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||
|
|
@ -2119,7 +2187,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("ReadingListItem", (string)null);
|
||||
b.ToTable("ReadingListItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||
|
|
@ -2164,7 +2232,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleError", (string)null);
|
||||
b.ToTable("ScrobbleError");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||
|
|
@ -2241,7 +2309,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleEvent", (string)null);
|
||||
b.ToTable("ScrobbleEvent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||
|
|
@ -2274,7 +2342,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleHold", (string)null);
|
||||
b.ToTable("ScrobbleHold");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Series", b =>
|
||||
|
|
@ -2380,7 +2448,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("Series", (string)null);
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||
|
|
@ -2397,7 +2465,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ServerSetting", (string)null);
|
||||
b.ToTable("ServerSetting");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||
|
|
@ -2435,7 +2503,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerStatistics", (string)null);
|
||||
b.ToTable("ServerStatistics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
|
|
@ -2491,7 +2559,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme", (string)null);
|
||||
b.ToTable("SiteTheme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
|
|
@ -2511,7 +2579,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tag", (string)null);
|
||||
b.ToTable("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||
|
|
@ -2581,7 +2649,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("Volume", (string)null);
|
||||
b.ToTable("Volume");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AppUserCollectionSeries", b =>
|
||||
|
|
@ -2596,7 +2664,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ItemsId");
|
||||
|
||||
b.ToTable("AppUserCollectionSeries", (string)null);
|
||||
b.ToTable("AppUserCollectionSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AppUserLibrary", b =>
|
||||
|
|
@ -2611,7 +2679,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibrariesId");
|
||||
|
||||
b.ToTable("AppUserLibrary", (string)null);
|
||||
b.ToTable("AppUserLibrary");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterGenre", b =>
|
||||
|
|
@ -2626,7 +2694,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("GenresId");
|
||||
|
||||
b.ToTable("ChapterGenre", (string)null);
|
||||
b.ToTable("ChapterGenre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
|
|
@ -2641,7 +2709,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTag", (string)null);
|
||||
b.ToTable("ChapterTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||
|
|
@ -2656,7 +2724,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
||||
b.ToTable("CollectionTagSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b =>
|
||||
|
|
@ -2671,7 +2739,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ExternalSeriesMetadatasId");
|
||||
|
||||
b.ToTable("ExternalRatingExternalSeriesMetadata", (string)null);
|
||||
b.ToTable("ExternalRatingExternalSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b =>
|
||||
|
|
@ -2686,7 +2754,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ExternalSeriesMetadatasId");
|
||||
|
||||
b.ToTable("ExternalRecommendationExternalSeriesMetadata", (string)null);
|
||||
b.ToTable("ExternalRecommendationExternalSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b =>
|
||||
|
|
@ -2701,7 +2769,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ExternalSeriesMetadatasId");
|
||||
|
||||
b.ToTable("ExternalReviewExternalSeriesMetadata", (string)null);
|
||||
b.ToTable("ExternalReviewExternalSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||
|
|
@ -2716,7 +2784,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("GenreSeriesMetadata", (string)null);
|
||||
b.ToTable("GenreSeriesMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||
|
|
@ -2815,7 +2883,26 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("SeriesMetadataTag", (string)null);
|
||||
b.ToTable("SeriesMetadataTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserAnnotation", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("Annotations")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.Chapter", "Chapter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||
|
|
@ -3604,6 +3691,8 @@ namespace API.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("API.Entities.AppUser", b =>
|
||||
{
|
||||
b.Navigation("Annotations");
|
||||
|
||||
b.Navigation("Bookmarks");
|
||||
|
||||
b.Navigation("ChapterRatings");
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ public interface IUserRepository
|
|||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
||||
Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
|
|
@ -550,13 +551,28 @@ public class UserRepository : IUserRepository
|
|||
/// </summary>
|
||||
/// <param name="deviceEmail"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByDeviceEmail(string deviceEmail)
|
||||
public async Task<AppUser?> GetUserByDeviceEmail(string deviceEmail)
|
||||
{
|
||||
return await _context.AppUser
|
||||
.Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of annotations ordered by page number. If the user has
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<List<AnnotationDto>> GetAnnotations(int userId, int chapterId)
|
||||
{
|
||||
// TODO: Check settings if I should include other user's annotations
|
||||
return await _context.AppUserAnnotation
|
||||
.Where(a => a.AppUserId == userId && a.ChapterId == chapterId)
|
||||
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
/// A list of Table of Contents for a given Chapter
|
||||
/// </summary>
|
||||
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
||||
public ICollection<AppUserAnnotation> Annotations { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// An API Key to interact with external services, like OPDS
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace API.Entities;
|
|||
/// <summary>
|
||||
/// Represents an annotation in the Epub reader
|
||||
/// </summary>
|
||||
public class Annotation : IEntityDate
|
||||
public class AppUserAnnotation : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
|
|
@ -15,7 +15,7 @@ public class Annotation : IEntityDate
|
|||
/// </summary>
|
||||
public required string XPath { get; set; }
|
||||
/// <summary>
|
||||
/// Ending point of the Hightlight. Can be the same as <see cref="XPath"/>
|
||||
/// Ending point of the Highlight. Can be the same as <see cref="XPath"/>
|
||||
/// </summary>
|
||||
public string EndingXPath { get; set; }
|
||||
|
||||
|
|
@ -24,11 +24,19 @@ public class Annotation : IEntityDate
|
|||
/// </summary>
|
||||
public string SelectedText { get; set; }
|
||||
/// <summary>
|
||||
/// Rich text Comment
|
||||
/// </summary>
|
||||
public string? Comment { get; set; }
|
||||
/// <summary>
|
||||
/// The number of characters selected
|
||||
/// </summary>
|
||||
public int HighlightCount { get; set; }
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
public HightlightColor HightlightColor { get; set; }
|
||||
public HightlightColor HighlightColor { get; set; }
|
||||
public bool ContainsSpoiler { get; set; }
|
||||
|
||||
// TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?)
|
||||
|
||||
public required int SeriesId { get; set; }
|
||||
public required int VolumeId { get; set; }
|
||||
|
|
@ -386,7 +386,9 @@ public class AutoMapperProfiles : Profile
|
|||
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
||||
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
||||
|
||||
|
||||
CreateMap<AppUserAnnotation, AnnotationDto>()
|
||||
.ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName))
|
||||
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId));
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
|
||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks);
|
||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
|
||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||
}
|
||||
|
||||
|
|
@ -340,13 +340,13 @@ public class BookService : IBookService
|
|||
}
|
||||
|
||||
|
||||
private static void InjectHighlights(HtmlDocument doc, EpubBookRef book, List<PersonalToCDto> ptocBookmarks)
|
||||
private static void InjectAnnotations(HtmlDocument doc, EpubBookRef book, List<AnnotationDto> annotations)
|
||||
{
|
||||
if (ptocBookmarks.Count == 0) return;
|
||||
if (annotations.Count == 0) return;
|
||||
|
||||
foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
|
||||
foreach (var annotation in annotations.Where(b => !string.IsNullOrEmpty(b.XPath)))
|
||||
{
|
||||
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 unscopedSelector = annotation.XPath.Replace("//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]", "//BODY").ToLowerInvariant();
|
||||
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
|
||||
if (elem == null) continue;
|
||||
|
||||
|
|
@ -355,7 +355,7 @@ public class BookService : IBookService
|
|||
var originalText = elem.InnerText;
|
||||
|
||||
// For POC: highlight first 16 characters
|
||||
const int highlightLength = 16;
|
||||
var highlightLength = annotation.HighlightCount;
|
||||
|
||||
if (originalText.Length > highlightLength)
|
||||
{
|
||||
|
|
@ -366,7 +366,7 @@ public class BookService : IBookService
|
|||
elem.RemoveAllChildren();
|
||||
|
||||
// Create the highlight element with the first 16 characters
|
||||
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{highlightedText}</app-epub-highlight>");
|
||||
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight id=\"epub-highlight-{annotation.Id}\">{highlightedText}</app-epub-highlight>");
|
||||
elem.AppendChild(highlightNode);
|
||||
|
||||
// Add the remaining text as a text node
|
||||
|
|
@ -376,7 +376,7 @@ public class BookService : IBookService
|
|||
else
|
||||
{
|
||||
// If text is shorter than highlight length, wrap it all
|
||||
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight>{originalText}</app-epub-highlight>");
|
||||
var highlightNode = HtmlNode.CreateNode($"<app-epub-highlight id=\"epub-highlight-{annotation.Id}\">{originalText}</app-epub-highlight>");
|
||||
elem.RemoveAllChildren();
|
||||
elem.AppendChild(highlightNode);
|
||||
}
|
||||
|
|
@ -1083,7 +1083,8 @@ public class BookService : IBookService
|
|||
/// <param name="page">Page number we are loading</param>
|
||||
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
|
||||
/// <returns></returns>
|
||||
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks)
|
||||
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
|
||||
Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
|
||||
{
|
||||
await InlineStyles(doc, book, apiBase, body);
|
||||
|
||||
|
|
@ -1094,9 +1095,7 @@ public class BookService : IBookService
|
|||
// Inject PTOC Bookmark Icons
|
||||
InjectPTOCBookmarks(doc, book, ptocBookmarks);
|
||||
|
||||
|
||||
// MOCK: This will mimic a highlight
|
||||
InjectHighlights(doc, book, ptocBookmarks);
|
||||
InjectAnnotations(doc, book, annotations);
|
||||
|
||||
return PrepareFinalHtml(doc, body);
|
||||
}
|
||||
|
|
@ -1288,7 +1287,8 @@ public class BookService : IBookService
|
|||
/// <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>
|
||||
/// <exception cref="KavitaException">All exceptions throw this</exception>
|
||||
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks)
|
||||
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl,
|
||||
List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
|
||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||
|
|
@ -1330,7 +1330,7 @@ public class BookService : IBookService
|
|||
body = doc.DocumentNode.SelectSingleNode("/html/body");
|
||||
}
|
||||
|
||||
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks);
|
||||
return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations);
|
||||
}
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import {ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, inject, Injectable} from '@angular/core';
|
||||
import {AnnotationCardComponent} from '../book-reader/_components/annotation-card/annotation-card.component';
|
||||
import {
|
||||
AnnotationCardComponent
|
||||
} from '../book-reader/_components/_annotations/annotation-card/annotation-card.component';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
|
|||
65
UI/Web/src/app/_services/epub-reader-menu.service.ts
Normal file
65
UI/Web/src/app/_services/epub-reader-menu.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {inject, Injectable} from '@angular/core';
|
||||
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
|
||||
import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {
|
||||
ViewAnnotationDrawerComponent
|
||||
} from "../book-reader/_components/_drawers/view-annotation-drawer/view-annotation-drawer.component";
|
||||
import {
|
||||
CreateAnnotationDrawerComponent
|
||||
} from "../book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component";
|
||||
import {
|
||||
ViewBookmarkDrawerComponent
|
||||
} from "../book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
|
||||
import {ActivatedRoute} from "@angular/router";
|
||||
import {ViewTocDrawerComponent} from "../book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component";
|
||||
|
||||
/**
|
||||
* Responsible for opening the different readers and providing any context needed. Handles closing or keeping a stack of menus open.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EpubReaderMenuService {
|
||||
|
||||
private readonly offcanvasService = inject(NgbOffcanvas);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
|
||||
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) {
|
||||
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
|
||||
ref.componentInstance.createAnnotation.set(annotation)
|
||||
}
|
||||
|
||||
|
||||
openViewAnnotationsDrawer(chapterId: number) {
|
||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
||||
this.offcanvasService.dismiss();
|
||||
}
|
||||
const ref = this.offcanvasService.open(ViewAnnotationDrawerComponent, {position: 'end', panelClass: ''});
|
||||
}
|
||||
|
||||
openTocDrawer() {
|
||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
||||
this.offcanvasService.dismiss();
|
||||
}
|
||||
const ref = this.offcanvasService.open(ViewTocDrawerComponent, {position: 'end', panelClass: ''});
|
||||
}
|
||||
|
||||
openViewBookmarksDrawer(chapterId: number) {
|
||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
||||
this.offcanvasService.dismiss();
|
||||
}
|
||||
const ref = this.offcanvasService.open(ViewBookmarkDrawerComponent, {position: 'end', panelClass: ''});
|
||||
ref.componentInstance.chapterId.set(chapterId);
|
||||
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
if (this.offcanvasService.hasOpenOffcanvas()) {
|
||||
this.offcanvasService.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ import {UtilityService} from "../shared/_services/utility.service";
|
|||
import {translate} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {Annotation} from "../book-reader/_models/annotation";
|
||||
import {CreateAnnotationRequest} from "../book-reader/_models/create-annotation-request";
|
||||
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
|
|
@ -330,6 +332,14 @@ export class ReaderService {
|
|||
return this.httpClient.post(this.baseUrl + 'reader/create-ptoc', {libraryId, seriesId, volumeId, chapterId, pageNumber, title, bookScrollId, selectedText});
|
||||
}
|
||||
|
||||
getAnnotations(chapterId: number) {
|
||||
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'reader/annotations?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
createAnnotation(data: CreateAnnotationRequest) {
|
||||
return this.httpClient.post<Array<Annotation>>(this.baseUrl + 'reader/create-annotation', data);
|
||||
}
|
||||
|
||||
getElementFromXPath(path: string) {
|
||||
const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
||||
|
|
@ -390,4 +400,5 @@ export class ReaderService {
|
|||
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
||||
{queryParams: {incognitoMode}});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {Component, input, model, output} from '@angular/core';
|
||||
import {UtcToLocaleDatePipe} from "../../../_pipes/utc-to-locale-date.pipe";
|
||||
import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe";
|
||||
import {DatePipe} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {Annotation} from "../../_models/annotation";
|
||||
import {Annotation} from "../../../_models/annotation";
|
||||
import {AnnotationCardComponent} from "../annotation-card/annotation-card.component";
|
||||
import {AnnotationCardService} from 'src/app/_service/annotation-card.service';
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export type HighlightColor = 'blue' | 'green';
|
|||
export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
showHighlight = model<boolean>(false);
|
||||
color = input<HighlightColor>('blue');
|
||||
annotation = input<Annotation | null>(null);
|
||||
annotation = model.required<Annotation | null>();
|
||||
isHovered = signal<boolean>(false);
|
||||
|
||||
@ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef;
|
||||
|
|
@ -225,7 +225,7 @@ export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestr
|
|||
if (!this.annotationCardRef) {
|
||||
this.annotationCardRef = this.annotationCardService.show({
|
||||
position: pos,
|
||||
annotationText: 'This is test text',
|
||||
annotationText: this.annotation()?.comment,
|
||||
createdDate: new Date('10/20/2025'),
|
||||
onMouseEnter: () => this.onMouseEnter(),
|
||||
onMouseLeave: () => this.onMouseLeave()
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<ng-container *transloco="let t; prefix: 'create-annotation-drawer'">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
{{t('title')}}
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body">
|
||||
|
||||
Hello
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model} from '@angular/core';
|
||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-annotation-drawer',
|
||||
imports: [
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './create-annotation-drawer.component.html',
|
||||
styleUrl: './create-annotation-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CreateAnnotationDrawerComponent {
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
createAnnotation = model<CreateAnnotationRequest | null>(null);
|
||||
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<ng-container *transloco="let t; prefix: 'view-annotation-drawer'">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
{{t('title')}}
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body">
|
||||
|
||||
Hello
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-annotation-drawer',
|
||||
imports: [
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './view-annotation-drawer.component.html',
|
||||
styleUrl: './view-annotation-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViewAnnotationDrawerComponent {
|
||||
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<ng-container *transloco="let t; prefix: 'view-bookmark-drawer'">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
{{t('title')}}
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body">
|
||||
|
||||
@let items = bookmarks();
|
||||
@if (items) {
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1" [childHeight]="1">
|
||||
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item) {
|
||||
<div>
|
||||
<app-image [imageUrl]="imageService.getBookmarkedImage(item.chapterId, item.pageNum)" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
}
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, model} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ReaderService} from "../../../../_services/reader.service";
|
||||
import {PageBookmark} from "../../../../_models/readers/page-bookmark";
|
||||
import {ImageService} from "../../../../_services/image.service";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {ImageComponent} from "../../../../shared/image/image.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-bookmarks-drawer',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
VirtualScrollerModule,
|
||||
ImageComponent
|
||||
],
|
||||
templateUrl: './view-bookmark-drawer.component.html',
|
||||
styleUrl: './view-bookmark-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViewBookmarkDrawerComponent {
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
||||
chapterId = model<number>();
|
||||
bookmarks = model<PageBookmark[]>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.chapterId();
|
||||
console.log('chapter id', id);
|
||||
if (!id) return;
|
||||
|
||||
this.readerService.getBookmarks(id).subscribe(bookmarks => {
|
||||
this.bookmarks.set(bookmarks);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<ng-container *transloco="let t; prefix: 'view-toc-drawer'">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
{{t('title')}}
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body">
|
||||
|
||||
Hello
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-toc-drawer',
|
||||
imports: [
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './view-toc-drawer.component.html',
|
||||
styleUrl: './view-toc-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViewTocDrawerComponent {
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,66 @@
|
|||
<ng-container *transloco="let t; read: 'book-line-overlay'">
|
||||
<div class="overlay" *ngIf="selectedText.length > 0 || mode !== BookLineOverlayMode.None">
|
||||
@if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) {
|
||||
<div class="overlay">
|
||||
|
||||
<div class="row g-0 justify-content-between">
|
||||
<ng-container [ngSwitch]="mode">
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.None">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="copy()">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
<div>{{t('copy')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||
<div>{{t('bookmark')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="reset()">
|
||||
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
|
||||
<div>{{t('close')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="BookLineOverlayMode.Bookmark">
|
||||
<form [formGroup]="bookmarkForm">
|
||||
<div class="input-group">
|
||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
|
||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
|
||||
<div id="bookmark-name-validations" class="invalid-feedback" *ngIf="bookmarkForm.dirty || bookmarkForm.touched">
|
||||
<div *ngIf="bookmarkForm.get('name')?.errors?.required" role="status">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 justify-content-between">
|
||||
@switch (mode) {
|
||||
@case (BookLineOverlayMode.None) {
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="copy()">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
<div>{{t('copy')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Annotate)">
|
||||
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
|
||||
<div>{{t('annotate')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="switchMode(BookLineOverlayMode.Bookmark)">
|
||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||
<div>{{t('bookmark')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-icon btn-sm" (click)="reset()">
|
||||
<i class="fa-solid fa-times-circle" aria-hidden="true"></i>
|
||||
<div>{{t('close')}}</div>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case (BookLineOverlayMode.Annotate) {
|
||||
|
||||
}
|
||||
|
||||
@case (BookLineOverlayMode.Bookmark) {
|
||||
<form [formGroup]="bookmarkForm">
|
||||
<div class="input-group">
|
||||
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('book-label')"
|
||||
[class.is-invalid]="bookmarkForm.get('name')?.invalid && bookmarkForm.get('name')?.touched" aria-describedby="bookmark-name-btn">
|
||||
<button class="btn btn-outline-primary" id="bookmark-name-btn" (click)="createPTOC()">{{t('save')}}</button>
|
||||
@if (bookmarkForm.dirty || bookmarkForm.touched) {
|
||||
<div id="bookmark-name-validations" class="invalid-feedback">
|
||||
@if (bookmarkForm.get('name')?.errors?.required) {
|
||||
<div role="status">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
OnInit,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {fromEvent, merge, of} from "rxjs";
|
||||
import {catchError} from "rxjs/operators";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
|
@ -20,15 +19,19 @@ import {ReaderService} from "../../../_services/reader.service";
|
|||
import {ToastrService} from "ngx-toastr";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {KEY_CODES} from "../../../shared/_services/utility.service";
|
||||
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
|
||||
import {CreateAnnotationRequest} from "../../_models/create-annotation-request";
|
||||
import {HightlightColor} from "../../_models/annotation";
|
||||
|
||||
enum BookLineOverlayMode {
|
||||
None = 0,
|
||||
Bookmark = 1
|
||||
Annotate = 1,
|
||||
Bookmark = 2
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-line-overlay',
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective],
|
||||
imports: [ReactiveFormsModule, TranslocoDirective],
|
||||
templateUrl: './book-line-overlay.component.html',
|
||||
styleUrls: ['./book-line-overlay.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
@ -53,9 +56,12 @@ export class BookLineOverlayComponent implements OnInit {
|
|||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
private readonly epubMenuService = inject(EpubReaderMenuService);
|
||||
|
||||
protected readonly BookLineOverlayMode = BookLineOverlayMode;
|
||||
|
||||
get BookLineOverlayMode() { return BookLineOverlayMode; }
|
||||
constructor(private elementRef: ElementRef, private toastr: ToastrService) {}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
|
|
@ -124,6 +130,26 @@ export class BookLineOverlayComponent implements OnInit {
|
|||
if (this.mode === BookLineOverlayMode.Bookmark) {
|
||||
this.bookmarkForm.get('name')?.setValue(this.selectedText);
|
||||
this.focusOnBookmarkInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mode === BookLineOverlayMode.Annotate) {
|
||||
// TODO: Open annotation drawer
|
||||
this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.xPath, this.selectedText
|
||||
const createAnnotation = {
|
||||
chapterId: this.chapterId,
|
||||
libraryId: this.libraryId,
|
||||
volumeId: this.volumeId,
|
||||
comment: null,
|
||||
selectedText: this.selectedText,
|
||||
containsSpoiler: false,
|
||||
pageNumber: this.pageNumber,
|
||||
xpath: this.xPath,
|
||||
endingXPath: this.xPath, // TODO: Figure this out
|
||||
highlightCount: this.selectedText.length,
|
||||
hightlightColor: HightlightColor.Blue
|
||||
} as CreateAnnotationRequest;
|
||||
this.epubMenuService.openCreateAnnotationDrawer(createAnnotation);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<ng-container *transloco="let t; read: 'book-reader'">
|
||||
<div class="fixed-top" #stickyTop>
|
||||
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-header')}}</a>
|
||||
<ng-container [ngTemplateOutlet]="actionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="topActionBar" [ngTemplateOutletContext]="{isTop: true}"></ng-container>
|
||||
<app-book-line-overlay [parent]="bookContainerElemRef" *ngIf="page !== undefined"
|
||||
[libraryId]="libraryId"
|
||||
[volumeId]="volumeId"
|
||||
|
|
@ -134,6 +134,56 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ng-template #topActionBar let-isTop>
|
||||
@if (!immersiveMode || drawerOpen || actionBarVisible) {
|
||||
<div class="action-bar d-flex align-items-center px-2">
|
||||
<!-- Left: Drawer toggle -->
|
||||
<button class="btn btn-secondary me-2" (click)="toggleDrawer()">
|
||||
<i class="fa fa-bars" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Center: Book Title -->
|
||||
<div class="flex-grow-1 text-center d-none d-md-block">
|
||||
@if (isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
||||
<span class="visually-hidden">{{ t('loading-book') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span *ngIf="incognitoMode" (click)="turnOffIncognito()" role="button" [attr.aria-label]="t('incognito-mode-alt')">
|
||||
(<i class="fa fa-glasses" aria-hidden="true"></i><span class="visually-hidden">{{ t('incognito-mode-label') }}</span>)
|
||||
</span>
|
||||
<span class="ms-1 book-title-text" [ngbTooltip]="bookTitle">{{ bookTitle }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right: Buttons -->
|
||||
<div class="d-flex align-items-center ms-auto">
|
||||
@if (!this.adhocPageHistory.isEmpty()) {
|
||||
<button class="btn btn-outline-secondary btn-icon me-1" (click)="goBack()" [title]="t('go-back')">
|
||||
<i class="fa fa-reply" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-secondary btn-icon me-1" (click)="closeReader()">
|
||||
<i class="fa fa-times-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewBookmarksDrawer(this.chapterId)">
|
||||
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-icon me-1" (click)="epubMenuService.openViewAnnotationsDrawer(this.chapterId)">
|
||||
<i class="fa-solid fa-highlighter" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-icon" (click)="epubMenuService.openTocDrawer()">
|
||||
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
|
||||
|
||||
<ng-template #actionBar let-isTop>
|
||||
<div class="action-bar row g-0 justify-content-between" *ngIf="!immersiveMode || drawerOpen || actionBarVisible">
|
||||
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ import {
|
|||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
import {EpubHighlightComponent} from "../epub-highlight/epub-highlight.component";
|
||||
import {EpubHighlightComponent} from "../_annotations/epub-highlight/epub-highlight.component";
|
||||
import {Annotation} from "../../_models/annotation";
|
||||
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
|
||||
|
||||
|
||||
enum TabID {
|
||||
|
|
@ -136,6 +138,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
private readonly themeService = inject(ThemeService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly epubMenuService = inject(EpubReaderMenuService);
|
||||
|
||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
||||
protected readonly WritingStyle = WritingStyle;
|
||||
|
|
@ -338,6 +341,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
*/
|
||||
hidePagination = false;
|
||||
|
||||
annotations: Array<Annotation> = [];
|
||||
|
||||
/**
|
||||
* Used to refresh the Personal PoC
|
||||
*/
|
||||
|
|
@ -1096,23 +1101,43 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.readerService.getAnnotations(this.chapterId).subscribe(annotations => {
|
||||
this.annotations = annotations;
|
||||
this.setupAnnotationElements();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
private setupAnnotationElements() {
|
||||
|
||||
const annoationMap: {[key: number]: Annotation} = this.annotations.reduce((map, obj) => {
|
||||
// @ts-ignore
|
||||
map[obj.id] = obj;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
// Make the highlight components "real"
|
||||
const highlightElems = this.document.querySelectorAll('app-epub-highlight');
|
||||
|
||||
for (let i = 0; i < highlightElems.length; i++) {
|
||||
const highlight = highlightElems[i];
|
||||
const idAttr = highlight.getAttribute('id');
|
||||
|
||||
// Don't allow highlight injection unless the id is present
|
||||
if (!idAttr) continue;
|
||||
|
||||
|
||||
const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
|
||||
const componentRef = this.readingContainer.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
|
||||
{projectableNodes: [[document.createTextNode(highlight.innerHTML)]]});
|
||||
if (highlight.parentNode != null) {
|
||||
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
|
||||
}
|
||||
|
||||
// TODO: Load the highlight instance with information from the Annotation
|
||||
|
||||
//componentRef.instance.cdRef.markForCheck();
|
||||
componentRef.instance.annotation.set(annoationMap[annotationId]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private addEmptyPageIfRequired(): void {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ export interface Annotation {
|
|||
xpath: string;
|
||||
endingXPath: string | null;
|
||||
selectedText: string | null;
|
||||
noteText: string;
|
||||
highlightCount: number;
|
||||
comment: string;
|
||||
hightlightColor: HightlightColor;
|
||||
containsSpoiler: boolean;
|
||||
pageNumber: number;
|
||||
|
||||
|
||||
seriesId: number;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
|
||||
ownerUserId: number;
|
||||
ownerUsername: string;
|
||||
createdUtc: string;
|
||||
lastModifiedUtc: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import {HightlightColor} from "./annotation";
|
||||
|
||||
export interface CreateAnnotationRequest {
|
||||
libraryId: number;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
xpath: string;
|
||||
endingXPath: string | null;
|
||||
selectedText: string | null;
|
||||
comment: string | null;
|
||||
hightlightColor: HightlightColor;
|
||||
highlightCount: number;
|
||||
containsSpoiler: boolean;
|
||||
pageNumber: number;
|
||||
}
|
||||
|
|
@ -800,12 +800,33 @@
|
|||
"book-line-overlay": {
|
||||
"copy": "Copy",
|
||||
"bookmark": "Bookmark",
|
||||
"annotate": "Annotate",
|
||||
"close": "{{common.close}}",
|
||||
"required-field": "{{common.required-field}}",
|
||||
"bookmark-label": "Bookmark Name",
|
||||
"save": "{{common.save}}"
|
||||
},
|
||||
|
||||
"view-annotation-drawer": {
|
||||
"title": "Annotations",
|
||||
"close": "{{common.close}}"
|
||||
},
|
||||
|
||||
"view-bookmark-drawer": {
|
||||
"title": "Image Bookmarks",
|
||||
"close": "{{common.close}}"
|
||||
},
|
||||
|
||||
"view-toc-drawer": {
|
||||
"title": "Table of Contents",
|
||||
"close": "{{common.close}}"
|
||||
},
|
||||
|
||||
"create-annotation-drawer": {
|
||||
"title": "Create/Edit an Annotation",
|
||||
"close": "{{common.close}}"
|
||||
},
|
||||
|
||||
"book-reader": {
|
||||
"title": "Book Settings",
|
||||
"page-label": "Page",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue