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