mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
Added ability to bookmark images in the epub reader.
This commit is contained in:
parent
6340867ba0
commit
f59d1de559
@ -16,6 +16,7 @@ using API.Extensions;
|
|||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks.Metadata;
|
using API.Services.Tasks.Metadata;
|
||||||
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -734,6 +735,7 @@ public class ReaderController : BaseApiController
|
|||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
||||||
|
|
||||||
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
||||||
|
|
||||||
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
|
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
|
||||||
|
|
||||||
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
|
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
|
||||||
@ -743,6 +745,39 @@ public class ReaderController : BaseApiController
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a bookmark for an epub image
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bookmarkDto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("bookmark-epub")]
|
||||||
|
public async Task<ActionResult> BookmarkEpubPage(BookmarkDto bookmarkDto)
|
||||||
|
{
|
||||||
|
|
||||||
|
// Don't let user save past total pages.
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||||
|
if (user == null) return new UnauthorizedResult();
|
||||||
|
|
||||||
|
if (!await _accountService.HasBookmarkPermission(user))
|
||||||
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
|
||||||
|
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
|
||||||
|
|
||||||
|
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
|
||||||
|
|
||||||
|
var cachedFilePath = _cacheService.GetCachedFile(chapter);
|
||||||
|
var path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath);
|
||||||
|
|
||||||
|
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
|
||||||
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
|
||||||
|
|
||||||
|
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a bookmarked page for a Chapter
|
/// Removes a bookmarked page for a Chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -15,6 +15,10 @@ public sealed record BookmarkDto
|
|||||||
[Required]
|
[Required]
|
||||||
public int ChapterId { get; set; }
|
public int ChapterId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Only applicable for Epubs
|
||||||
|
/// </summary>
|
||||||
|
public int ImageOffset { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// This is only used when getting all bookmarks.
|
/// This is only used when getting all bookmarks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SeriesDto? Series { get; set; }
|
public SeriesDto? Series { get; set; }
|
||||||
|
@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace API.Data.Migrations
|
namespace API.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(DataContext))]
|
[DbContext(typeof(DataContext))]
|
||||||
[Migration("20250708204811_BookAnnotations")]
|
[Migration("20250710004325_BookAnnotations")]
|
||||||
partial class BookAnnotations
|
partial class BookAnnotations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -241,6 +241,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ImageOffset")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime>("LastModified")
|
b.Property<DateTime>("LastModified")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
@ -23,6 +23,13 @@ namespace API.Data.Migrations
|
|||||||
type: "TEXT",
|
type: "TEXT",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ImageOffset",
|
||||||
|
table: "AppUserBookmark",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AppUserAnnotation",
|
name: "AppUserAnnotation",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@ -87,6 +94,10 @@ namespace API.Data.Migrations
|
|||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "SelectedText",
|
name: "SelectedText",
|
||||||
table: "AppUserTableOfContent");
|
table: "AppUserTableOfContent");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ImageOffset",
|
||||||
|
table: "AppUserBookmark");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -238,6 +238,9 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("FileName")
|
b.Property<string>("FileName")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ImageOffset")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime>("LastModified")
|
b.Property<DateTime>("LastModified")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
@ -19,7 +19,11 @@ public class AppUserBookmark : IEntityDate
|
|||||||
/// Filename in the Bookmark Directory
|
/// Filename in the Bookmark Directory
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string FileName { get; set; } = string.Empty;
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Only applicable for Epubs - handles multiple images on one page
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>0-based index of the image position on page</remarks>
|
||||||
|
public int ImageOffset { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
@ -61,6 +61,7 @@ public interface IBookService
|
|||||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
|
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations);
|
||||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||||
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
|
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
|
||||||
|
Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class BookService : IBookService
|
public partial class BookService : IBookService
|
||||||
@ -735,6 +736,7 @@ public partial class BookService : IBookService
|
|||||||
|
|
||||||
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
|
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
|
||||||
{
|
{
|
||||||
|
// TODO: Refactor this to use the Async version
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||||
@ -1001,6 +1003,109 @@ public partial class BookService : IBookService
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath)
|
||||||
|
{
|
||||||
|
using var book = await EpubReader.OpenBookAsync(cachedBookPath, LenientBookReaderOptions);
|
||||||
|
|
||||||
|
var counter = 0;
|
||||||
|
var doc = new HtmlDocument { OptionFixNestedTags = true };
|
||||||
|
|
||||||
|
var bookPages = await book.GetReadingOrderAsync();
|
||||||
|
foreach (var contentFileRef in bookPages)
|
||||||
|
{
|
||||||
|
if (bookmarkDto.Page != counter || contentFileRef.ContentType != EpubContentType.XHTML_1_1)
|
||||||
|
{
|
||||||
|
counter++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await contentFileRef.ReadContentAsync();
|
||||||
|
doc.LoadHtml(content);
|
||||||
|
|
||||||
|
var images = doc.DocumentNode.SelectNodes("//img")
|
||||||
|
?? doc.DocumentNode.SelectNodes("//image");
|
||||||
|
|
||||||
|
if (images == null || images.Count == 0)
|
||||||
|
{
|
||||||
|
throw new KavitaException("No images found on the specified page");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookmarkDto.ImageOffset >= images.Count)
|
||||||
|
{
|
||||||
|
throw new KavitaException($"Image index {bookmarkDto.ImageOffset} is out of range. Page has {images.Count} images");
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetImage = images[bookmarkDto.ImageOffset];
|
||||||
|
|
||||||
|
// Get the image source attribute
|
||||||
|
string? srcAttributeName = null;
|
||||||
|
if (targetImage.Attributes["src"] != null)
|
||||||
|
{
|
||||||
|
srcAttributeName = "src";
|
||||||
|
}
|
||||||
|
else if (targetImage.Attributes["xlink:href"] != null)
|
||||||
|
{
|
||||||
|
srcAttributeName = "xlink:href";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(srcAttributeName))
|
||||||
|
{
|
||||||
|
throw new KavitaException("Image element does not have a valid source attribute");
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageSource = targetImage.Attributes[srcAttributeName].Value;
|
||||||
|
|
||||||
|
// Clean and get the correct key for the image
|
||||||
|
var imageKey = CleanContentKeys(GetKeyForImage(book, imageSource));
|
||||||
|
|
||||||
|
// Check if it's an external URL
|
||||||
|
if (imageKey.StartsWith("http"))
|
||||||
|
{
|
||||||
|
throw new KavitaException("Cannot copy external images");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the image file from the epub
|
||||||
|
|
||||||
|
if (!book.Content.Images.TryGetLocalFileRefByKey(imageKey, out var imageFile))
|
||||||
|
{
|
||||||
|
throw new KavitaException($"Image file not found in epub: {imageKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the image content
|
||||||
|
var imageContent = await imageFile.ReadContentAsBytesAsync();
|
||||||
|
|
||||||
|
// Determine file extension from the image key or content type
|
||||||
|
var extension = Path.GetExtension(imageKey);
|
||||||
|
if (string.IsNullOrEmpty(extension))
|
||||||
|
{
|
||||||
|
// Fallback to determining extension from content type
|
||||||
|
extension = imageFile.ContentType switch
|
||||||
|
{
|
||||||
|
EpubContentType.IMAGE_JPEG => ".jpg",
|
||||||
|
EpubContentType.IMAGE_PNG => ".png",
|
||||||
|
EpubContentType.IMAGE_GIF => ".gif",
|
||||||
|
EpubContentType.IMAGE_SVG => ".svg",
|
||||||
|
_ => ".png"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp directory for this chapter if it doesn't exist
|
||||||
|
var tempChapterDir = Path.Combine(_directoryService.TempDirectory, chapterId.ToString());
|
||||||
|
_directoryService.ExistOrCreate(tempChapterDir);
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
var uniqueFilename = $"{Guid.NewGuid()}{extension}";
|
||||||
|
var tempFilePath = Path.Combine(tempChapterDir, uniqueFilename);
|
||||||
|
|
||||||
|
// Write the image to the temp file
|
||||||
|
await File.WriteAllBytesAsync(tempFilePath, imageContent);
|
||||||
|
|
||||||
|
return tempFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
||||||
/// then null is returned. This expects only an epub file
|
/// then null is returned. This expects only an epub file
|
||||||
@ -1153,6 +1258,7 @@ public partial class BookService : IBookService
|
|||||||
|
|
||||||
RewriteAnchors(page, doc, mappings);
|
RewriteAnchors(page, doc, mappings);
|
||||||
|
|
||||||
|
// TODO: Pass bookmarks here for state management
|
||||||
ScopeImages(doc, book, apiBase);
|
ScopeImages(doc, book, apiBase);
|
||||||
|
|
||||||
InjectImages(doc, book, apiBase);
|
InjectImages(doc, book, apiBase);
|
||||||
@ -1160,8 +1266,10 @@ public partial class BookService : IBookService
|
|||||||
// Inject PTOC Bookmark Icons
|
// Inject PTOC Bookmark Icons
|
||||||
InjectPTOCBookmarks(doc, book, ptocBookmarks);
|
InjectPTOCBookmarks(doc, book, ptocBookmarks);
|
||||||
|
|
||||||
|
// Inject Annotations
|
||||||
InjectAnnotations(doc, book, annotations);
|
InjectAnnotations(doc, book, annotations);
|
||||||
|
|
||||||
|
|
||||||
return PrepareFinalHtml(doc, body);
|
return PrepareFinalHtml(doc, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1226,88 +1334,6 @@ public partial class BookService : IBookService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
|
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);
|
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
|
||||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||||
|
|
||||||
@ -1318,7 +1344,7 @@ public partial class BookService : IBookService
|
|||||||
{
|
{
|
||||||
foreach (var navigationItem in navItems)
|
foreach (var navigationItem in navItems)
|
||||||
{
|
{
|
||||||
var tocItem = CreateToCChapterRecursively(book, navigationItem, mappings);
|
var tocItem = CreateToCChapter(book, navigationItem, mappings);
|
||||||
if (tocItem != null)
|
if (tocItem != null)
|
||||||
{
|
{
|
||||||
chaptersList.Add(tocItem);
|
chaptersList.Add(tocItem);
|
||||||
@ -1368,7 +1394,7 @@ public partial class BookService : IBookService
|
|||||||
return chaptersList;
|
return chaptersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BookChapterItem? CreateToCChapterRecursively(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
|
private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
|
||||||
{
|
{
|
||||||
// Get the page mapping for the current navigation item
|
// Get the page mapping for the current navigation item
|
||||||
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
|
var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
|
||||||
@ -1384,7 +1410,7 @@ public partial class BookService : IBookService
|
|||||||
{
|
{
|
||||||
foreach (var nestedItem in navigationItem.NestedItems)
|
foreach (var nestedItem in navigationItem.NestedItems)
|
||||||
{
|
{
|
||||||
var childItem = CreateToCChapterRecursively(book, nestedItem, mappings);
|
var childItem = CreateToCChapter(book, nestedItem, mappings);
|
||||||
if (childItem != null)
|
if (childItem != null)
|
||||||
{
|
{
|
||||||
children.Add(childItem);
|
children.Add(childItem);
|
||||||
@ -1407,8 +1433,9 @@ public partial class BookService : IBookService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountParentDirectory(string path)
|
private static int CountParentDirectory(string? path)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return 0;
|
||||||
return ParentDirectoryRegex().Matches(path).Count;
|
return ParentDirectoryRegex().Matches(path).Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
@ -113,12 +114,17 @@ public class BookmarkService : IBookmarkService
|
|||||||
/// <param name="bookmarkDto"></param>
|
/// <param name="bookmarkDto"></param>
|
||||||
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
|
/// <param name="imageToBookmark">Full path to the cached image that is going to be copied</param>
|
||||||
/// <returns>If the save to DB and copy was successful</returns>
|
/// <returns>If the save to DB and copy was successful</returns>
|
||||||
public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
|
public async Task<bool> BookmarkPage(AppUser? userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
|
||||||
{
|
{
|
||||||
if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false;
|
if (userWithBookmarks?.Bookmarks == null)
|
||||||
|
{
|
||||||
|
throw new KavitaException("Bookmarks cannot be null!");
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId);
|
var userBookmark = userWithBookmarks.Bookmarks
|
||||||
|
.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId && b.ImageOffset == bookmarkDto.ImageOffset);
|
||||||
if (userBookmark != null)
|
if (userBookmark != null)
|
||||||
{
|
{
|
||||||
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
|
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
|
||||||
@ -137,6 +143,7 @@ public class BookmarkService : IBookmarkService
|
|||||||
SeriesId = bookmarkDto.SeriesId,
|
SeriesId = bookmarkDto.SeriesId,
|
||||||
ChapterId = bookmarkDto.ChapterId,
|
ChapterId = bookmarkDto.ChapterId,
|
||||||
FileName = Path.Join(targetFolderStem, fileInfo.Name),
|
FileName = Path.Join(targetFolderStem, fileInfo.Name),
|
||||||
|
ImageOffset = bookmarkDto.ImageOffset,
|
||||||
AppUserId = userWithBookmarks.Id
|
AppUserId = userWithBookmarks.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,7 +177,7 @@ public class BookmarkService : IBookmarkService
|
|||||||
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
|
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
|
||||||
{
|
{
|
||||||
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
|
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
|
||||||
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
|
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page && x.ImageOffset == bookmarkDto.ImageOffset);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (bookmarkToDelete != null)
|
if (bookmarkToDelete != null)
|
||||||
|
@ -342,9 +342,7 @@ public class CacheService : ICacheService
|
|||||||
// Calculate what chapter the page belongs to
|
// Calculate what chapter the page belongs to
|
||||||
var path = GetCachePath(chapterId);
|
var path = GetCachePath(chapterId);
|
||||||
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
|
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
|
||||||
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
|
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions);
|
||||||
//.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
return GetPageFromFiles(files, page);
|
return GetPageFromFiles(files, page);
|
||||||
}
|
}
|
||||||
|
@ -356,7 +356,7 @@ public class ReaderService : IReaderService
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
|
private static int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
|
||||||
{
|
{
|
||||||
if (volume.IsSpecial())
|
if (volume.IsSpecial())
|
||||||
{
|
{
|
||||||
|
@ -6,5 +6,9 @@ export interface PageBookmark {
|
|||||||
seriesId: number;
|
seriesId: number;
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
|
/**
|
||||||
|
* Only present on epub-based Bookmarks
|
||||||
|
*/
|
||||||
|
imageOffset: number;
|
||||||
series: Series;
|
series: Series;
|
||||||
}
|
}
|
||||||
|
@ -102,14 +102,19 @@ export class ReaderService {
|
|||||||
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
return `${this.baseUrl}reader/pdf?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, xPath: string | null = null) {
|
||||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
|
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page, xPath});
|
||||||
}
|
}
|
||||||
|
|
||||||
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number = 0) {
|
||||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page, imageNumber});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bookmarkEpub(seriesId: number, volumeId: number, chapterId: number, page: number, imageNumber: number) {
|
||||||
|
return this.httpClient.post(this.baseUrl + 'reader/bookmark-epub', {seriesId, volumeId, chapterId, page, imageNumber});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
||||||
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
|
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
|
||||||
}
|
}
|
||||||
|
@ -355,9 +355,7 @@ $pagination-opacity: 0;
|
|||||||
//$pagination-color: red;
|
//$pagination-color: red;
|
||||||
//$pagination-opacity: 0.7;
|
//$pagination-opacity: 0.7;
|
||||||
|
|
||||||
.kavita-scale-width::after {
|
|
||||||
content: ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
|
@ -62,6 +62,7 @@ import {ColumnLayoutClassPipe} from "../../_pipes/column-layout-class.pipe";
|
|||||||
import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe";
|
import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe";
|
||||||
import {ChapterService} from "../../../_services/chapter.service";
|
import {ChapterService} from "../../../_services/chapter.service";
|
||||||
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
|
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
|
||||||
|
import {PageBookmark} from "../../../_models/readers/page-bookmark";
|
||||||
|
|
||||||
|
|
||||||
interface HistoryPoint {
|
interface HistoryPoint {
|
||||||
@ -128,6 +129,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
protected readonly epubMenuService = inject(EpubReaderMenuService);
|
protected readonly epubMenuService = inject(EpubReaderMenuService);
|
||||||
protected readonly readerSettingsService = inject(EpubReaderSettingsService);
|
protected readonly readerSettingsService = inject(EpubReaderSettingsService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
||||||
protected readonly WritingStyle = WritingStyle;
|
protected readonly WritingStyle = WritingStyle;
|
||||||
@ -271,6 +273,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
imageBookmarks = model<PageBookmark[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchors that map to the page number. When you click on one of these, we will load a given page up for the user.
|
* Anchors that map to the page number. When you click on one of these, we will load a given page up for the user.
|
||||||
*/
|
*/
|
||||||
@ -309,9 +313,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
||||||
|
|
||||||
//writingStyle: WritingStyle = WritingStyle.Horizontal;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the user is highlighting something, then we remove pagination
|
* When the user is highlighting something, then we remove pagination
|
||||||
*/
|
*/
|
||||||
@ -324,7 +325,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
refreshPToC: EventEmitter<void> = new EventEmitter<void>();
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
|
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
|
||||||
/**
|
/**
|
||||||
@ -646,6 +646,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.nextChapterPrefetched = false;
|
this.nextChapterPrefetched = false;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
this.loadImageBookmarks();
|
||||||
|
|
||||||
|
|
||||||
this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => {
|
this.bookService.getBookInfo(this.chapterId, true).subscribe(async (info) => {
|
||||||
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
|
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
|
||||||
@ -796,6 +798,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadImageBookmarks() {
|
||||||
|
this.readerService.getBookmarks(this.chapterId).subscribe(res => {
|
||||||
|
this.imageBookmarks.set(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
loadNextChapter() {
|
loadNextChapter() {
|
||||||
if (this.nextPageDisabled) { return; }
|
if (this.nextPageDisabled) { return; }
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
@ -969,11 +977,82 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
this.setupPage(part, scrollTop);
|
this.setupPage(part, scrollTop);
|
||||||
this.updateImageSizes();
|
this.updateImageSizes();
|
||||||
|
this.injectImageBookmarkIndicators();
|
||||||
});
|
});
|
||||||
}, 10);
|
}, 10);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the new DOM needed to provide the bookmark functionality.
|
||||||
|
* We can't use a wrapper due to potential for styling issues.
|
||||||
|
*/
|
||||||
|
injectImageBookmarkIndicators() {
|
||||||
|
const imgs = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('img') ?? []);
|
||||||
|
|
||||||
|
|
||||||
|
const bookmarksForPage = (this.imageBookmarks() ?? []).filter(b => b.page === this.pageNum());
|
||||||
|
|
||||||
|
imgs.forEach((img, index) => {
|
||||||
|
if (img.nextElementSibling?.classList.contains('bookmark-overlay')) return;
|
||||||
|
|
||||||
|
const matchingBookmarks = bookmarksForPage.filter(b => b.imageOffset == index);
|
||||||
|
|
||||||
|
let hasBookmark = false;
|
||||||
|
if (matchingBookmarks.length > 0) {
|
||||||
|
hasBookmark = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'bookmark-overlay ' + (hasBookmark ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||||
|
|
||||||
|
//icon.attributes.title = hasBookmark ? 'Unbookmark' : 'Bookmark';
|
||||||
|
icon.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 16px;
|
||||||
|
pointer-events: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Make parent relative if needed
|
||||||
|
const parent = img.parentElement;
|
||||||
|
if (parent == null) return;
|
||||||
|
|
||||||
|
if (getComputedStyle(parent).position === 'static') {
|
||||||
|
parent.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.appendChild(icon);
|
||||||
|
|
||||||
|
icon.addEventListener('click', () => {
|
||||||
|
if (hasBookmark) {
|
||||||
|
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
|
||||||
|
const newState = !hasBookmark;
|
||||||
|
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||||
|
hasBookmark = !hasBookmark;
|
||||||
|
this.loadImageBookmarks();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.readerService.bookmarkEpub(this.seriesId, this.volumeId, this.chapterId, this.pageNum(), index).subscribe(bookmark => {
|
||||||
|
const newState = !hasBookmark;
|
||||||
|
icon.className = 'bookmark-overlay ' + (newState ? 'fa-solid' : 'fa-regular') + ' fa-bookmark';
|
||||||
|
hasBookmark = !hasBookmark;
|
||||||
|
this.loadImageBookmarks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the image properties to fit the current layout mode and screen size
|
* Updates the image properties to fit the current layout mode and screen size
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user