PToC (now known as Text Bookmarks) have been refactored and done (except some small css).

This commit is contained in:
Joseph Milazzo 2025-07-08 17:50:17 -05:00
parent 64ee5ee459
commit 6340867ba0
16 changed files with 361 additions and 106 deletions

View file

@ -934,6 +934,11 @@ public class ReaderController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
}
// Look up the chapter this PTOC is associated with to get the chapter title (if there is one)
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
var toc = await _bookService.GenerateTableOfContents(chapter);
var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber);
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
{
@ -944,6 +949,7 @@ public class ReaderController : BaseApiController
LibraryId = dto.LibraryId,
BookScrollId = dto.BookScrollId,
SelectedText = dto.SelectedText,
ChapterTitle = chapterTitle,
AppUserId = userId
});
await _unitOfWork.CommitAsync();

View file

@ -6,7 +6,25 @@ public sealed record PersonalToCDto
{
public required int Id { get; init; }
public required int ChapterId { get; set; }
/// <summary>
/// The page to bookmark
/// </summary>
public required int PageNumber { get; set; }
/// <summary>
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
/// </summary>
public required string Title { get; set; }
/// <summary>
/// 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>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
/// <summary>
/// Title of the Chapter this PToC was created in
/// </summary>
/// <remarks>Taken from the ToC</remarks>
public string? ChapterTitle { get; set; }
}

View file

@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250704153900_BookAnnotations")]
[Migration("20250708204811_BookAnnotations")]
partial class BookAnnotations
{
/// <inheritdoc />
@ -892,6 +892,9 @@ namespace API.Data.Migrations
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -1410,6 +1413,9 @@ namespace API.Data.Migrations
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<bool>("RemovePrefixForSortName")
.HasColumnType("INTEGER");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");

View file

@ -11,6 +11,12 @@ namespace API.Data.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ChapterTitle",
table: "AppUserTableOfContent",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SelectedText",
table: "AppUserTableOfContent",
@ -74,6 +80,10 @@ namespace API.Data.Migrations
migrationBuilder.DropTable(
name: "AppUserAnnotation");
migrationBuilder.DropColumn(
name: "ChapterTitle",
table: "AppUserTableOfContent");
migrationBuilder.DropColumn(
name: "SelectedText",
table: "AppUserTableOfContent");

View file

@ -889,6 +889,9 @@ namespace API.Data.Migrations
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("ChapterTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");

View file

@ -18,6 +18,19 @@ public class AppUserTableOfContent : IEntityDate
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
/// </summary>
public required string Title { get; set; }
/// <summary>
/// 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>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
/// <summary>
/// Title of the Chapter this PToC was created in
/// </summary>
/// <remarks>Taken from the ToC</remarks>
public string? ChapterTitle { get; set; }
public required int SeriesId { get; set; }
public virtual Series Series { get; set; }
@ -27,14 +40,7 @@ public class AppUserTableOfContent : IEntityDate
public int VolumeId { get; set; }
public int LibraryId { get; set; }
/// <summary>
/// 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>
public string? BookScrollId { get; set; }
/// <summary>
/// Text of the bookmark
/// </summary>
public string? SelectedText { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }

View file

@ -63,7 +63,7 @@ public interface IBookService
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
}
public class BookService : IBookService
public partial class BookService : IBookService
{
private readonly ILogger<BookService> _logger;
private readonly IDirectoryService _directoryService;
@ -1226,6 +1226,88 @@ public class BookService : IBookService
/// <returns></returns>
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
{
// using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
// var mappings = await CreateKeyToPageMappingAsync(book);
//
// var navItems = await book.GetNavigationAsync();
// var chaptersList = new List<BookChapterItem>();
//
// if (navItems != null)
// {
// foreach (var navigationItem in navItems)
// {
// if (navigationItem.NestedItems.Count == 0)
// {
// CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
// continue;
// }
//
// var nestedChapters = new List<BookChapterItem>();
//
// foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
// {
// var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
// if (mappings.TryGetValue(key, out var mapping))
// {
// nestedChapters.Add(new BookChapterItem
// {
// Title = nestedChapter.Title,
// Page = mapping,
// Part = nestedChapter.Link?.Anchor ?? string.Empty,
// Children = []
// });
// }
// }
//
// CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
// }
// }
//
// if (chaptersList.Count != 0) return chaptersList;
// // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
// var tocPage = book.Content.Html.Local.Select(s => s.Key)
// .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
// k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
// if (string.IsNullOrEmpty(tocPage)) return chaptersList;
//
//
// // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
// if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
// var content = await file.ReadContentAsync();
//
// var doc = new HtmlDocument();
// doc.LoadHtml(content);
//
// // TODO: We may want to check if there is a toc.ncs file to better handle nested toc
// // We could do a fallback first with ol/lis
//
//
//
// var anchors = doc.DocumentNode.SelectNodes("//a");
// if (anchors == null) return chaptersList;
//
// foreach (var anchor in anchors)
// {
// if (!anchor.Attributes.Contains("href")) continue;
//
// var key = CoalesceKey(book, mappings, anchor.Attributes["href"].Value.Split("#")[0]);
//
// if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue;
// var part = string.Empty;
// if (anchor.Attributes["href"].Value.Contains('#'))
// {
// part = anchor.Attributes["href"].Value.Split("#")[1];
// }
// chaptersList.Add(new BookChapterItem
// {
// Title = anchor.InnerText,
// Page = mappings[key],
// Part = part,
// Children = []
// });
// }
//
// return chaptersList;
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
var mappings = await CreateKeyToPageMappingAsync(book);
@ -1236,53 +1318,29 @@ public class BookService : IBookService
{
foreach (var navigationItem in navItems)
{
if (navigationItem.NestedItems.Count == 0)
var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings);
if (tocItem != null)
{
CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
continue;
chaptersList.Add(tocItem);
}
var nestedChapters = new List<BookChapterItem>();
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
{
var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
if (mappings.TryGetValue(key, out var mapping))
{
nestedChapters.Add(new BookChapterItem
{
Title = nestedChapter.Title,
Page = mapping,
Part = nestedChapter.Link?.Anchor ?? string.Empty,
Children = new List<BookChapterItem>()
});
}
}
CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
}
}
if (chaptersList.Count != 0) return chaptersList;
// Rest of your fallback logic remains the same...
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
var tocPage = book.Content.Html.Local.Select(s => s.Key)
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
var content = await file.ReadContentAsync();
var doc = new HtmlDocument();
doc.LoadHtml(content);
// TODO: We may want to check if there is a toc.ncs file to better handle nested toc
// We could do a fallback first with ol/lis
var anchors = doc.DocumentNode.SelectNodes("//a");
if (anchors == null) return chaptersList;
@ -1303,19 +1361,55 @@ public class BookService : IBookService
Title = anchor.InnerText,
Page = mappings[key],
Part = part,
Children = new List<BookChapterItem>()
Children = []
});
}
return chaptersList;
}
private BookChapterItem? CreateToCChapterRecursively(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
{
// Get the page mapping for the current navigation item
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
int? page = null;
if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping))
{
page = mapping;
}
// Recursively process nested items
var children = new List<BookChapterItem>();
if (navigationItem.NestedItems?.Count > 0)
{
foreach (var nestedItem in navigationItem.NestedItems)
{
var childItem = CreateToCChapterRecursively(book, nestedItem, mappings);
if (childItem != null)
{
children.Add(childItem);
}
}
}
// Only create a BookChapterItem if we have a valid page or children
if (page.HasValue || children.Count > 0)
{
return new BookChapterItem
{
Title = navigationItem.Title ?? string.Empty,
Page = page ?? 0, // You might want to handle this differently
Part = navigationItem.Link?.Anchor ?? string.Empty,
Children = children
};
}
return null;
}
private static int CountParentDirectory(string path)
{
const string pattern = @"\.\./";
var matches = Regex.Matches(path, pattern);
return matches.Count;
return ParentDirectoryRegex().Matches(path).Count;
}
/// <summary>
@ -1481,6 +1575,28 @@ public class BookService : IBookService
return string.Empty;
}
public static string? GetChapterTitleFromToC(ICollection<BookChapterItem>? tableOfContents, int pageNumber)
{
if (tableOfContents == null) return null;
foreach (var item in tableOfContents)
{
// Check if current item matches the page number
if (item.Page == pageNumber)
return item.Title;
// Recursively search children if they exist
if (item.Children?.Count > 0)
{
var childResult = GetChapterTitleFromToC(item.Children, pageNumber);
if (childResult != null)
return childResult;
}
}
return null;
}
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
@ -1570,4 +1686,7 @@ public class BookService : IBookService
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
}
}
[GeneratedRegex(@"\.\./")]
private static partial Regex ParentDirectoryRegex();
}

View file

@ -3,6 +3,9 @@ export interface PersonalToC {
pageNumber: number;
title: string;
bookScrollId: string | undefined;
selectedText: string | null;
chapterTitle: string | null;
/* Ui Only */
position: 0;
}

View file

@ -41,7 +41,7 @@
@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')"
<input id="bookmark-name" class="form-control" formControlName="name" type="text" [placeholder]="t('bookmark-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) {

View file

@ -355,6 +355,10 @@ $pagination-opacity: 0;
//$pagination-color: red;
//$pagination-opacity: 0.7;
.kavita-scale-width::after {
content: ' ';
}
.right {
position: absolute;

View file

@ -1,32 +1,36 @@
<ng-container *transloco="let t; read: 'personal-table-of-contents'">
<ng-container *transloco="let t; prefix: 'personal-table-of-contents'">
<div class="table-of-contents">
@if (Pages.length === 0) {
@let bookmarks = ptocBookmarks();
@if(bookmarks.length >= ShowFilterAfterItems) {
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
</div>
</div>
</div>
</form>
}
@for(bookmark of bookmarks | filter: filterList; track bookmark.pageNumber + bookmark.title) {
<div class="mb-2">
<app-text-bookmark-item [bookmark]="bookmark"
(loadBookmark)="loadChapterPage($event.pageNumber, $event.bookScrollId)"
(removeBookmark)="removeBookmark($event)" />
</div>
} @empty {
<div>
<em>{{t('no-data')}}</em>
@if (formGroup.get('filter')?.value) {
<em>{{t('no-match')}}</em>
} @else {
<em>{{t('no-data')}}</em>
}
</div>
}
<ul>
@for (page of Pages; track page) {
<li>
<span (click)="loadChapterPage(page, '')">{{t('page', {value: page})}}</span>
<ul class="chapter-title">
@for(bookmark of bookmarks[page]; track bookmark) {
<li class="ellipsis"
[ngbTooltip]="bookmark.title"
placement="right"
(click)="loadChapterPage(bookmark.pageNumber, bookmark.bookScrollId); $event.stopPropagation();">
{{bookmark.title}}
<button class="btn btn-icon ms-1" (click)="removeBookmark(bookmark); $event.stopPropagation();">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete', {bookmarkName: bookmark.title})}}</span>
</button>
</li>
}
</ul>
</li>
}
</ul>
</div>
</ng-container>

View file

@ -1,19 +1,22 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef, EventEmitter,
Inject,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
model,
OnInit,
Output
} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {ReaderService} from "../../../_services/reader.service";
import {PersonalToC} from "../../../_models/readers/personal-toc";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {TextBookmarkItemComponent} from "../text-bookmark-item/text-bookmark-item.component";
import {ConfirmService} from "../../../shared/confirm.service";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
export interface PersonalToCEvent {
pageNum: number;
@ -21,31 +24,32 @@ export interface PersonalToCEvent {
}
@Component({
selector: 'app-personal-table-of-contents',
imports: [NgbTooltip, TranslocoDirective],
templateUrl: './personal-table-of-contents.component.html',
styleUrls: ['./personal-table-of-contents.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-personal-table-of-contents',
imports: [TranslocoDirective, TextBookmarkItemComponent, FilterPipe, FormsModule, ReactiveFormsModule],
templateUrl: './personal-table-of-contents.component.html',
styleUrls: ['./personal-table-of-contents.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonalTableOfContentsComponent implements OnInit {
private readonly readerService = inject(ReaderService);
private readonly destroyRef = inject(DestroyRef);
private readonly confirmService = inject(ConfirmService);
protected readonly ShowFilterAfterItems = 10;
@Input({required: true}) chapterId!: number;
@Input({required: true}) pageNum: number = 0;
@Input({required: true}) tocRefresh!: EventEmitter<void>;
@Output() loadChapter: EventEmitter<PersonalToCEvent> = new EventEmitter();
private readonly readerService = inject(ReaderService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
bookmarks: {[key: number]: Array<PersonalToC>} = [];
get Pages() {
return Object.keys(this.bookmarks).map(p => parseInt(p, 10));
}
constructor(@Inject(DOCUMENT) private document: Document) {}
ptocBookmarks = model<PersonalToC[]>([]);
formGroup = new FormGroup({
filter: new FormControl('', [])
});
ngOnInit() {
this.tocRefresh.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
@ -57,13 +61,7 @@ export class PersonalTableOfContentsComponent implements OnInit {
load() {
this.readerService.getPersonalToC(this.chapterId).subscribe(res => {
res.forEach(t => {
if (!this.bookmarks.hasOwnProperty(t.pageNumber)) {
this.bookmarks[t.pageNumber] = [];
}
this.bookmarks[t.pageNumber].push(t);
})
this.cdRef.markForCheck();
this.ptocBookmarks.set(res);
});
}
@ -71,15 +69,18 @@ export class PersonalTableOfContentsComponent implements OnInit {
this.loadChapter.emit({pageNum, scrollPart});
}
removeBookmark(bookmark: PersonalToC) {
this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => {
this.bookmarks[bookmark.pageNumber] = this.bookmarks[bookmark.pageNumber].filter(t => t.title != bookmark.title);
async removeBookmark(bookmark: PersonalToC) {
if (this.bookmarks[bookmark.pageNumber].length === 0) {
delete this.bookmarks[bookmark.pageNumber];
}
this.cdRef.markForCheck();
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-bookmark'))) return;
this.readerService.removePersonalToc(bookmark.chapterId, bookmark.pageNumber, bookmark.title).subscribe(() => {
this.ptocBookmarks.set(this.ptocBookmarks().filter(t => t.title !== bookmark.title));
});
}
filterList = (listItem: PersonalToC) => {
const query = (this.formGroup.get('filter')?.value || '').toLowerCase();
return listItem.title.toLowerCase().indexOf(query) >= 0 || listItem.pageNumber.toString().indexOf(query) >= 0 || (listItem.chapterTitle ?? '').toLowerCase().indexOf(query) >= 0;
}
}

View file

@ -0,0 +1,29 @@
<ng-container *transloco="let t; prefix: 'personal-table-of-contents'">
@let ptoc = bookmark();
@if (ptoc) {
<div class="card clickable" (click)="goTo($event)">
<div class="card-body">
<div class="card-title" [ngbTooltip]="ptoc.title" placement="left" container="body">
<span class="ellipsis">{{ptoc.title}}</span>
<button class="btn btn-icon ms-auto" (click)="remove($event)">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete', {bookmarkName: ptoc.title})}}</span>
</button>
</div>
<div class="subtitle text-muted">
@if (ptoc.chapterTitle) {
Chapter "{{ptoc.chapterTitle}}" -
}
{{t('page', {value: ptoc.pageNumber})}}
</div>
</div>
</div>
}
</ng-container>

View file

@ -0,0 +1,7 @@
.card:hover {
background-color: var(--elevation-layer7)
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,36 @@
import {Component, EventEmitter, input, Output} from '@angular/core';
import {PersonalToC} from "../../../_models/readers/personal-toc";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-text-bookmark-item',
imports: [
NgbTooltip,
TranslocoDirective
],
templateUrl: './text-bookmark-item.component.html',
styleUrl: './text-bookmark-item.component.scss'
})
export class TextBookmarkItemComponent {
bookmark = input.required<PersonalToC>();
@Output() loadBookmark = new EventEmitter<PersonalToC>();
@Output() removeBookmark = new EventEmitter<PersonalToC>();
remove(evt: Event) {
evt.stopPropagation();
evt.preventDefault();
this.removeBookmark.emit(this.bookmark());
}
goTo(evt: Event) {
evt.stopPropagation();
evt.preventDefault();
this.loadBookmark.emit(this.bookmark());
}
}

View file

@ -874,7 +874,9 @@
"personal-table-of-contents": {
"no-data": "Nothing Bookmarked yet",
"page": "Page {{value}}",
"delete": "Delete {{bookmarkName}}"
"delete": "Delete {{bookmarkName}}",
"no-match": "No Bookmarks match filter",
"filter-label": "{{common.filter}}"
},
"confirm-email": {
@ -2779,7 +2781,8 @@
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}",
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes."
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes.",
"confirm-delete-bookmark": "Are you sure you want to delete this Bookmark?"
},
"read-time-pipe": {