Browse Source

feat: search suggestions + results UI improvements

Nick 6 years ago
parent
commit
f7664339f4

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 
 ## [2.0.0-beta.XX] - 2019-XX-XX
 ### Added
+- Added Search Results overlay
+- Added Search Engine - PostgreSQL
+- Added Search Engine - DB Basic
 - Added Git changes processing (add/modify/delete)
 - Added Storage last sync date in status panel
 - Added Dev Flags

+ 6 - 1
client/components/common/nav-header.vue

@@ -76,6 +76,8 @@
               @keyup.esc='searchClose'
               @focus='searchFocus'
               @blur='searchBlur'
+              @keyup.down='searchMove(`down`)'
+              @keyup.up='searchMove(`up`)'
             )
               v-progress-linear(
                 indeterminate,
@@ -253,7 +255,10 @@ export default {
       }
     },
     searchEnter() {
-      this.searchIsLoading = true
+      this.$root.$emit('searchEnter', true)
+    },
+    searchMove(dir) {
+      this.$root.$emit('searchMove', dir)
     },
     pageNew () {
       this.newPageModal = true

+ 41 - 4
client/components/common/search-results.vue

@@ -16,9 +16,9 @@
         .subheading No pages matching your query.
       template(v-if='results.length > 0')
         v-subheader.white--text Found {{response.totalHits}} results
-        v-list.radius-7(two-line)
+        v-list.search-results-items.radius-7(two-line)
           template(v-for='(item, idx) of results')
-            v-list-tile(@click='', :key='item.id')
+            v-list-tile(@click='goToPage(item)', :key='item.id', :class='idx === cursor ? `highlighted` : ``')
               v-list-tile-avatar(tile)
                 img(src='/svg/icon-selective-highlighting.svg')
               v-list-tile-content
@@ -36,9 +36,9 @@
         )
       template(v-if='suggestions.length > 0')
         v-subheader.white--text.mt-3 Did you mean...
-        v-list.radius-7(dense, dark)
+        v-list.search-results-suggestions.radius-7(dense, dark)
           template(v-for='(term, idx) of suggestions')
-            v-list-tile(:key='term', @click='setSearchTerm(term)')
+            v-list-tile(:key='term', @click='setSearchTerm(term)', :class='idx + results.length === cursor ? `highlighted` : ``')
               v-list-tile-avatar
                 v-icon search
               v-list-tile-content
@@ -66,6 +66,7 @@ export default {
   },
   data() {
     return {
+      cursor: 0,
       pagination: 1,
       response: {
         results: [],
@@ -95,16 +96,40 @@ export default {
   },
   watch: {
     search(newValue, oldValue) {
+      this.cursor = 0
       if (newValue.length < 2) {
         this.response.results = []
+        this.response.suggestions = []
+      } else {
+        this.searchIsLoading = true
       }
     }
   },
   methods: {
     setSearchTerm(term) {
       this.search = term
+    },
+    goToPage(item) {
+      window.location.assign(`/${item.locale}/${item.path}`)
     }
   },
+  mounted() {
+    this.$root.$on('searchMove', (dir) => {
+      this.cursor += (dir === 'up' ? -1 : 1)
+      if (this.cursor < -1) {
+        this.cursor = -1
+      } else if (this.cursor > this.results.length + this.suggestions.length - 1) {
+        this.cursor = this.results.length + this.suggestions.length - 1
+      }
+    })
+    this.$root.$on('searchEnter', () => {
+      if (this.cursor >= 0 && this.cursor < this.results.length) {
+        this.goToPage(_.nth(this.results, this.cursor))
+      } else if (this.cursor >= 0) {
+        this.setSearchTerm(_.nth(this.suggestions, this.cursor - this.results.length))
+      }
+    })
+  },
   apollo: {
     response: {
       query: searchPagesQuery,
@@ -178,6 +203,18 @@ export default {
       width: 200px;
     }
   }
+
+  &-items {
+    .highlighted {
+      background-color: mc('blue', '50');
+    }
+  }
+
+  &-suggestions {
+    .highlighted {
+      background-color: mc('blue', '500');
+    }
+  }
 }
 
 @keyframes searchResultsReveal {

+ 30 - 13
server/modules/search/postgres/engine.js

@@ -12,7 +12,7 @@ module.exports = {
    * INIT
    */
   async init() {
-    // -> Create Index
+    // -> Create Search Index
     const indexExists = await WIKI.models.knex.schema.hasTable('pagesVector')
     if (!indexExists) {
       await WIKI.models.knex.schema.createTable('pagesVector', table => {
@@ -21,11 +21,19 @@ module.exports = {
         table.string('locale')
         table.string('title')
         table.string('description')
-        table.specificType('titleTk', 'TSVECTOR')
-        table.specificType('descriptionTk', 'TSVECTOR')
-        table.specificType('contentTk', 'TSVECTOR')
+        table.specificType('tokens', 'TSVECTOR')
       })
     }
+    // -> Create Words Index
+    const wordsExists = await WIKI.models.knex.schema.hasTable('pagesWords')
+    if (!wordsExists) {
+      await WIKI.models.knex.raw(`
+        CREATE TABLE "pagesWords" AS SELECT word FROM ts_stat(
+          'SELECT to_tsvector(''simple'', pages."title") || to_tsvector(''simple'', pages."description") || to_tsvector(''simple'', pages."content") FROM pages WHERE pages."isPublished" AND NOT pages."isPrivate"'
+        )`)
+      await WIKI.models.knex.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm')
+      await WIKI.models.knex.raw(`CREATE INDEX "pageWords_idx" ON "pagesWords" USING GIN (word gin_trgm_ops)`)
+    }
   },
   /**
    * QUERY
@@ -35,14 +43,20 @@ module.exports = {
    */
   async query(q, opts) {
     try {
+      let suggestions = []
       const results = await WIKI.models.knex.raw(`
         SELECT id, path, locale, title, description
         FROM "pagesVector", to_tsquery(?) query
-        WHERE (query @@ "titleTk") OR (query @@ "descriptionTk") OR (query @@ "contentTk")
+        WHERE query @@ "tokens"
+        ORDER BY ts_rank(tokens, query) DESC
       `, [tsquery(q)])
+      if (results.rows.length < 5) {
+        const suggestResults = await WIKI.models.knex.raw(`SELECT word, word <-> ? AS rank FROM "pagesWords" WHERE similarity(word, ?) > 0.2 ORDER BY rank LIMIT 5;`, [q, q])
+        suggestions = suggestResults.rows.map(r => r.word)
+      }
       return {
         results: results.rows,
-        suggestions: [],
+        suggestions,
         totalHits: results.rows.length
       }
     } catch (err) {
@@ -58,8 +72,8 @@ module.exports = {
    */
   async created(page) {
     await WIKI.models.knex.raw(`
-      INSERT INTO "pagesVector" (path, locale, title, description, "titleTk", "descriptionTk", "contentTk") VALUES (
-        '?', '?', '?', '?', to_tsvector('?'), to_tsvector('?'), to_tsvector('?')
+      INSERT INTO "pagesVector" (path, locale, title, description, tokens) VALUES (
+        '?', '?', '?', '?', (setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'A') || setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'B') || setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'C'))
       )
     `, [page.path, page.locale, page.title, page.description, page.title, page.description, page.content])
   },
@@ -73,9 +87,9 @@ module.exports = {
       UPDATE "pagesVector" SET
         title = '?',
         description = '?',
-        "titleTk" = to_tsvector('?'),
-        "descriptionTk" = to_tsvector('?'),
-        "contentTk" = to_tsvector('?')
+        tokens = (setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'A') ||
+        setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'B') ||
+        setweight(to_tsvector('${this.config.dictLanguage}', '?'), 'C'))
       WHERE path = '?' AND locale = '?' LIMIT 1
     `, [page.title, page.description, page.title, page.description, page.content, page.path, page.locale])
   },
@@ -110,8 +124,11 @@ module.exports = {
   async rebuild() {
     await WIKI.models.knex('pagesVector').truncate()
     await WIKI.models.knex.raw(`
-      INSERT INTO "pagesVector" (path, locale, title, description, "titleTk", "descriptionTk", "contentTk")
-        SELECT path, "localeCode" AS locale, title, description, to_tsvector(title) AS "titleTk", to_tsvector(description) AS "descriptionTk", to_tsvector(content) AS "contentTk"
+      INSERT INTO "pagesVector" (path, locale, title, description, "tokens")
+        SELECT path, "localeCode" AS locale, title, description,
+          (setweight(to_tsvector('${this.config.dictLanguage}', title), 'A') ||
+          setweight(to_tsvector('${this.config.dictLanguage}', description), 'B') ||
+          setweight(to_tsvector('${this.config.dictLanguage}', content), 'C')) AS tokens
         FROM "pages"
         WHERE pages."isPublished" AND NOT pages."isPrivate"`)
   }