From 5778200c8fafd569dc36626f0f67ced247ab6cc5 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:14:46 -0700 Subject: [PATCH 1/3] 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<ePub.NavItem>} */ - 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 @@ <component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" /> <!-- TOC side nav --> - <div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> + <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> - <div class="p-4 h-full"> + <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> <span class="material-icons text-2xl">arrow_back</span> @@ -36,13 +36,28 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> - <div class="tocContent"> + <form @submit.prevent="searchBook" @click.stop.prevent> + <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + </form> + + <div class="overflow-y-auto"> + <div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center"> + <p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p> + </div> + <ul> - <li v-for="chapter in chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a> + <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + </div> + <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + </div> </li> </ul> </li> @@ -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 { </script> <style> -.tocContent { - height: calc(100% - 36px); - overflow-y: auto; -} #reader { height: 100%; } diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index c347eea3..56825491 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,6 +68,7 @@ export default { methods: { clear() { this.inputValue = '' + this.$emit('submit') }, focused() { this.isFocused = true From 4229cb7fb6fce179c796b80417be8295fcd8f987 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:35:28 -0700 Subject: [PATCH 2/3] Added a method to unwrap the chapter list --- client/components/readers/EpubReader.vue | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 11e7bf9e..aa11d162 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -401,6 +401,26 @@ export default { this.chapters = tocTree }) }, + flattenChapters(chapters) { + // Convert the nested epub chapters into something that looks like audiobook chapters for player-ui + const unwrap = (chapters) => { + return chapters.reduce((acc, chapter) => { + return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter] + }, []) + } + let flattenedChapters = unwrap(chapters) + + flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start) + for (let i = 0; i < flattenedChapters.length; i++) { + flattenedChapters[i].id = i + if (i < flattenedChapters.length - 1) { + flattenedChapters[i].end = flattenedChapters[i + 1].start + } else { + flattenedChapters[i].end = 1 + } + } + return flattenedChapters + }, resize() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight From 6dc5b58d8e4df37a3c5a7153b81bc2c8a27506cb Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 14:32:11 -0500 Subject: [PATCH 3/3] Update TOC to not close when clicking on it --- client/components/readers/Reader.vue | 20 ++++++++++++-------- client/components/ui/TextInput.vue | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 2a7b90cf..5ee85182 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -27,7 +27,7 @@ <!-- TOC side nav --> <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> - <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> + <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent> <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> @@ -37,7 +37,7 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> <form @submit.prevent="searchBook" @click.stop.prevent> - <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + <ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> </form> <div class="overflow-y-auto"> @@ -47,16 +47,16 @@ <ul> <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a> <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> </div> <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a> <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> </div> </li> </ul> @@ -181,11 +181,11 @@ export default { font: [ { text: 'Sans', - value: 'sans-serif', + value: 'sans-serif' }, { text: 'Serif', - value: 'serif', + value: 'serif' } ] } @@ -272,6 +272,10 @@ export default { } }, methods: { + goToChapter(uri) { + this.toggleToC() + this.$refs.readerComponent.goToChapter(uri) + }, readerMounted() { if (this.isEpub) { this.loadEreaderSettings() diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index 56825491..5f871635 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,7 +68,7 @@ export default { methods: { clear() { this.inputValue = '' - this.$emit('submit') + this.$emit('clear') }, focused() { this.isFocused = true