From 5778200c8fafd569dc36626f0f67ced247ab6cc5 Mon Sep 17 00:00:00 2001 From: MxMarx Date: Fri, 27 Oct 2023 00:14:46 -0700 Subject: [PATCH] Make epubs searchable --- client/components/readers/EpubReader.vue | 88 ++++++++++++++++++++++-- client/components/readers/Reader.vue | 45 +++++++++--- client/components/ui/TextInput.vue | 1 + 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 7cc3c33a..11e7bf9e 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -40,6 +40,7 @@ export default { book: null, /** @type {ePub.Rendition} */ rendition: null, + chapters: [], ereaderSettings: { theme: 'dark', font: 'serif', @@ -68,10 +69,6 @@ export default { hasNext() { return !this.rendition?.location?.atEnd }, - /** @returns {Array} */ - chapters() { - return this.book?.navigation?.toc || [] - }, userMediaProgress() { if (!this.libraryItemId) return return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) @@ -146,6 +143,40 @@ export default { if (!this.rendition?.manager) return return this.rendition?.display(href) }, + /** @returns {object} Returns the chapter that the `position` in the book is in */ + findChapterFromPosition(chapters, position) { + let foundChapter + for (let i = 0; i < chapters.length; i++) { + if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) { + foundChapter = chapters[i] + if (chapters[i].subitems && chapters[i].subitems.length > 0) { + return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter) + } + break + } + } + return foundChapter + }, + /** @returns {Array} Returns an array of chapters that only includes chapters with query results */ + async searchBook(query) { + const chapters = structuredClone(await this.chapters) + const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item)))) + const mergedResults = [].concat(...searchResults) + + mergedResults.forEach((chapter) => { + chapter.start = this.book.locations.percentageFromCfi(chapter.cfi) + const foundChapter = this.findChapterFromPosition(chapters, chapter.start) + if (foundChapter) foundChapter.searchResults.push(chapter) + }) + + let filteredResults = chapters.filter(function f(o) { + if (o.searchResults.length) return true + if (o.subitems.length) { + return (o.subitems = o.subitems.filter(f)).length + } + }) + return filteredResults + }, keyUp(e) { const rtl = this.book.package.metadata.direction === 'rtl' if ((e.keyCode || e.which) == 37) { @@ -319,6 +350,55 @@ export default { this.checkSaveLocations(reader.book.locations.save()) }) } + this.getChapters() + }) + }, + getChapters() { + // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 + const toc = this.book?.navigation?.toc || [] + + const tocTree = [] + + const resolveURL = (url, relativeTo) => { + // see https://github.com/futurepress/epub.js/issues/1084 + // HACK-ish: abuse the URL API a little to resolve the path + // the base needs to be a valid URL, or it will throw a TypeError, + // so we just set a random base URI and remove it later + const base = 'https://example.invalid/' + return new URL(url, base + relativeTo).href.replace(base, '') + } + + const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath + + const createTree = async (toc, parent) => { + const promises = toc.map(async (tocItem, i) => { + const href = resolveURL(tocItem.href, basePath) + const id = href.split('#')[1] + const item = this.book.spine.get(href) + await item.load(this.book.load.bind(this.book)) + const el = id ? item.document.getElementById(id) : item.document.body + + const cfi = item.cfiFromElement(el) + + parent[i] = { + title: tocItem.label.trim(), + subitems: [], + href, + cfi, + start: this.book.locations.percentageFromCfi(cfi), + end: null, // set by flattenChapters() + id: null, // set by flattenChapters() + searchResults: [] + } + + if (tocItem.subitems) { + await createTree(tocItem.subitems, parent[i].subitems) + } + }) + await Promise.all(promises) + } + return createTree(toc, tocTree).then(() => { + this.chapters = tocTree }) }, resize() { diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 569ff84f..2a7b90cf 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -26,9 +26,9 @@ -
+
-
+
-
+
+ + + +
+
+

{{ $strings.MessageNoResults }}

+
+
    -
  • - {{ chapter.label }} +
  • + {{ chapter.title }} + +
  • @@ -105,6 +120,9 @@ export default { touchstartTime: 0, touchIdentifier: null, chapters: [], + isSearching: false, + searchResults: [], + searchQuery: '', tocOpen: false, showSettings: false, ereaderSettings: { @@ -281,6 +299,15 @@ export default { this.close() } }, + async searchBook() { + if (this.searchQuery.length > 1) { + this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery) + this.isSearching = true + } else { + this.isSearching = false + this.searchResults = [] + } + }, next() { if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next() }, @@ -359,6 +386,8 @@ export default { }, close() { this.unregisterListeners() + this.isSearching = false + this.searchQuery = '' this.show = false } }, @@ -372,10 +401,6 @@ export default {