PToC (now known as Text Bookmarks) have been refactored and done (except some small css).
This commit is contained in:
parent
64ee5ee459
commit
6340867ba0
16 changed files with 361 additions and 106 deletions
|
@ -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();
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
@ -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");
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -3,6 +3,9 @@ export interface PersonalToC {
|
|||
pageNumber: number;
|
||||
title: string;
|
||||
bookScrollId: string | undefined;
|
||||
selectedText: string | null;
|
||||
chapterTitle: string | null;
|
||||
/* Ui Only */
|
||||
position: 0;
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -355,6 +355,10 @@ $pagination-opacity: 0;
|
|||
//$pagination-color: red;
|
||||
//$pagination-opacity: 0.7;
|
||||
|
||||
.kavita-scale-width::after {
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
|
||||
.right {
|
||||
position: absolute;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
.card:hover {
|
||||
background-color: var(--elevation-layer7)
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue