From ab42e5e1ab283f02c03da32e0cc494cb0956759f Mon Sep 17 00:00:00 2001
From: Nick <github@ngpixel.com>
Date: Sat, 9 Mar 2019 18:43:32 -0500
Subject: [PATCH] feat: postgres search engine

---
 client/components/common/nav-header.vue     | 33 ++++++---
 client/components/common/search-results.vue | 34 +++++++--
 client/static/svg/icon-search-alt.svg       |  2 +
 client/store/site.js                        |  1 +
 dev/search-engines/solr/solrconfig.xml      |  2 +
 package.json                                |  1 +
 server/modules/search/db/engine.js          | 22 ------
 server/modules/search/postgres/engine.js    | 82 ++++++++++++++++-----
 8 files changed, 122 insertions(+), 55 deletions(-)
 create mode 100644 client/static/svg/icon-search-alt.svg
 create mode 100644 dev/search-engines/solr/solrconfig.xml

diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue
index c2b04683..8f4aa819 100644
--- a/client/components/common/nav-header.vue
+++ b/client/components/common/nav-header.vue
@@ -2,18 +2,18 @@
   v-toolbar.nav-header(color='black', dark, app, clipped-left, fixed, flat, :extended='searchIsShown && $vuetify.breakpoint.smAndDown')
     v-toolbar(color='deep-purple', flat, slot='extension', v-if='searchIsShown && $vuetify.breakpoint.smAndDown')
       v-text-field(
-        ref='searchFieldMobile',
-        v-model='search',
-        clearable,
+        ref='searchFieldMobile'
+        v-model='search'
+        clearable
         background-color='deep-purple'
-        color='white',
-        label='Search...',
-        single-line,
+        color='white'
+        label='Search...'
+        single-line
         solo
         flat
-        hide-details,
-        prepend-inner-icon='search',
-        :loading='searchIsLoading',
+        hide-details
+        prepend-inner-icon='search'
+        :loading='searchIsLoading'
         @keyup.enter='searchEnter'
       )
     v-layout(row)
@@ -73,7 +73,9 @@
               prepend-inner-icon='search',
               :loading='searchIsLoading',
               @keyup.enter='searchEnter'
-              @keyup.esc='search = ``'
+              @keyup.esc='searchClose'
+              @focus='searchFocus'
+              @blur='searchBlur'
             )
               v-progress-linear(
                 indeterminate,
@@ -191,6 +193,7 @@ export default {
   },
   computed: {
     search: sync('site/search'),
+    searchIsFocused: sync('site/searchIsFocused'),
     searchIsLoading: sync('site/searchIsLoading'),
     searchRestrictLocale: sync('site/searchRestrictLocale'),
     searchRestrictPath: sync('site/searchRestrictPath'),
@@ -231,6 +234,16 @@ export default {
     }
   },
   methods: {
+    searchFocus() {
+      this.searchIsFocused = true
+    },
+    searchBlur() {
+      this.searchIsFocused = false
+    },
+    searchClose() {
+      this.search = ''
+      this.searchBlur()
+    },
     searchToggle() {
       this.searchIsShown = !this.searchIsShown
       if (this.searchIsShown) {
diff --git a/client/components/common/search-results.vue b/client/components/common/search-results.vue
index 97ea42c4..c20eafdc 100644
--- a/client/components/common/search-results.vue
+++ b/client/components/common/search-results.vue
@@ -1,14 +1,17 @@
 <template lang="pug">
-  .search-results(v-if='search.length > 1')
+  .search-results(v-if='searchIsFocused || search.length > 1')
     .search-results-container
-      .search-results-loader(v-if='searchIsLoading && results.length < 1')
+      .search-results-help(v-if='search.length < 2')
+        img(src='/svg/icon-search-alt.svg')
+        .mt-4 Type at least 2 characters to start searching...
+      .search-results-loader(v-else-if='searchIsLoading && results.length < 1')
         orbit-spinner(
           :animation-duration='1000'
           :size='100'
           color='#FFF'
         )
         .headline.mt-5 Searching...
-      .search-results-none(v-if='!searchIsLoading && results.length < 1')
+      .search-results-none(v-else-if='!searchIsLoading && results.length < 1')
         img(src='/svg/icon-no-results.svg', alt='No Results')
         .subheading No pages matching your query.
       template(v-if='results.length > 0')
@@ -41,7 +44,7 @@
               v-list-tile-content
                 v-list-tile-title(v-html='term')
             v-divider(v-if='idx < suggestions.length - 1')
-      .text-xs-center.pt-4
+      .text-xs-center.pt-5(v-if='search.length > 1')
         v-btn(outline, color='orange', @click='search = ``', v-if='results.length > 0')
           v-icon(left) save
           span Copy Search Link
@@ -73,6 +76,7 @@ export default {
   },
   computed: {
     search: sync('site/search'),
+    searchIsFocused: sync('site/searchIsFocused'),
     searchIsLoading: sync('site/searchIsLoading'),
     searchRestrictLocale: sync('site/searchRestrictLocale'),
     searchRestrictPath: sync('site/searchRestrictPath'),
@@ -89,6 +93,13 @@ export default {
       return this.response.totalHits > 0 ? 0 : Math.ceil(this.response.totalHits / 10)
     }
   },
+  watch: {
+    search(newValue, oldValue) {
+      if (newValue.length < 2) {
+        this.response.results = []
+      }
+    }
+  },
   methods: {
     setSearchTerm(term) {
       this.search = term
@@ -102,7 +113,8 @@ export default {
           query: this.search
         }
       },
-      fetchPolicy: 'cache-and-network',
+      fetchPolicy: 'network-only',
+      debounce: 300,
       throttle: 1000,
       skip() {
         return !this.search || this.search.length < 2
@@ -138,6 +150,18 @@ export default {
     max-width: 1024px;
   }
 
+  &-help {
+    text-align: center;
+    padding: 32px 0;
+    font-size: 18px;
+    font-weight: 300;
+    color: #FFF;
+
+    img {
+      width: 104px;
+    }
+  }
+
   &-loader {
     display: flex;
     justify-content: center;
diff --git a/client/static/svg/icon-search-alt.svg b/client/static/svg/icon-search-alt.svg
new file mode 100644
index 00000000..ed63ce83
--- /dev/null
+++ b/client/static/svg/icon-search-alt.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 192 192" width="104px" height="104px"><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,192v-192h192v192z" fill="none"/><g fill="#ffffff"><g id="surface1"><path d="M73.84615,1.38462c-40.03846,0 -72.46154,32.42308 -72.46154,72.46154c0,40.03846 32.42308,72.46154 72.46154,72.46154c16.90385,0 32.45192,-5.97116 44.76923,-15.69231l6.46154,6.46154c-2.71153,5.10577 -1.75961,11.625 2.53846,15.92308l33.92308,34.15385c5.27885,5.27885 13.875,5.27885 19.15385,0l6.46154,-6.46154c5.27885,-5.27885 5.27885,-13.875 0,-19.15385l-34.15385,-33.92308c-4.32692,-4.32692 -10.81731,-5.07692 -15.92308,-2.30769l-6.46154,-6.46154c9.77885,-12.34615 15.69231,-28.00962 15.69231,-45c0,-40.03846 -32.42308,-72.46154 -72.46154,-72.46154zM73.84615,14.76923c32.625,0 59.07692,26.45192 59.07692,59.07692c0,32.625 -26.45192,59.07692 -59.07692,59.07692c-32.625,0 -59.07692,-26.45192 -59.07692,-59.07692c0,-32.625 26.45192,-59.07692 59.07692,-59.07692zM36.46154,55.15385c-3.80769,6.17308 -6,13.44231 -6,21.23077c0,22.35577 18.02884,40.38462 40.38462,40.38462c8.625,0 16.73077,-2.79808 23.30769,-7.38462c-1.75961,0.20192 -3.72115,0.23077 -5.53846,0.23077c-28.90384,0 -52.15385,-23.25 -52.15385,-52.15385c0,-0.77885 -0.02884,-1.52884 0,-2.30769z"/></g></g></g></svg>
diff --git a/client/store/site.js b/client/store/site.js
index 68035a93..dc79571c 100644
--- a/client/store/site.js
+++ b/client/store/site.js
@@ -8,6 +8,7 @@ const state = {
   mascot: true,
   title: siteConfig.title,
   search: '',
+  searchIsFocused: false,
   searchIsLoading: false,
   searchRestrictLocale: false,
   searchRestrictPath: false
diff --git a/dev/search-engines/solr/solrconfig.xml b/dev/search-engines/solr/solrconfig.xml
new file mode 100644
index 00000000..e109177a
--- /dev/null
+++ b/dev/search-engines/solr/solrconfig.xml
@@ -0,0 +1,2 @@
+<config>
+</config>
diff --git a/package.json b/package.json
index 28ff2b62..de232327 100644
--- a/package.json
+++ b/package.json
@@ -135,6 +135,7 @@
     "pem-jwk": "2.0.0",
     "pg": "7.8.0",
     "pg-hstore": "2.3.2",
+    "pg-tsquery": "8.0.3",
     "pm2": "3.2.9",
     "pug": "2.0.3",
     "qr-image": "3.2.0",
diff --git a/server/modules/search/db/engine.js b/server/modules/search/db/engine.js
index 1721a3bd..cc5a9a3d 100644
--- a/server/modules/search/db/engine.js
+++ b/server/modules/search/db/engine.js
@@ -13,28 +13,6 @@ module.exports = {
   init() {
     // not used
   },
-  /**
-   * SUGGEST
-   *
-   * @param {String} q Query
-   * @param {Object} opts Additional options
-   */
-  async suggest(q, opts) {
-    const results = await WIKI.models.pages.query()
-      .column('title')
-      .where(builder => {
-        builder.where('isPublished', true)
-        if (opts.locale) {
-          builder.andWhere('locale', opts.locale)
-        }
-        if (opts.path) {
-          builder.andWhere('path', 'like', `${opts.path}%`)
-        }
-        builder.andWhere('title', 'like', `%${q}%`)
-      })
-      .limit(10)
-    return _.uniq(_.filter(_.flatten(results.map(r => r.title.split(' '))), w => w.indexOf(q) >= 0))
-  },
   /**
    * QUERY
    *
diff --git a/server/modules/search/postgres/engine.js b/server/modules/search/postgres/engine.js
index d3178be5..6885a443 100644
--- a/server/modules/search/postgres/engine.js
+++ b/server/modules/search/postgres/engine.js
@@ -1,26 +1,31 @@
 const _ = require('lodash')
+const tsquery = require('pg-tsquery')()
 
 module.exports = {
-  activate() {
+  async activate() {
     // not used
   },
-  deactivate() {
+  async deactivate() {
     // not used
   },
   /**
    * INIT
    */
-  init() {
-    // not used
-  },
-  /**
-   * SUGGEST
-   *
-   * @param {String} q Query
-   * @param {Object} opts Additional options
-   */
-  async suggest(q, opts) {
-
+  async init() {
+    // -> Create Index
+    const indexExists = await WIKI.models.knex.schema.hasTable('pagesVector')
+    if (!indexExists) {
+      await WIKI.models.knex.schema.createTable('pagesVector', table => {
+        table.increments()
+        table.string('path')
+        table.string('locale')
+        table.string('title')
+        table.string('description')
+        table.specificType('titleTk', 'TSVECTOR')
+        table.specificType('descriptionTk', 'TSVECTOR')
+        table.specificType('contentTk', 'TSVECTOR')
+      })
+    }
   },
   /**
    * QUERY
@@ -29,6 +34,21 @@ module.exports = {
    * @param {Object} opts Additional options
    */
   async query(q, opts) {
+    try {
+      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")
+      `, [tsquery(q)])
+      return {
+        results: results.rows,
+        suggestions: [],
+        totalHits: results.rows.length
+      }
+    } catch (err) {
+      WIKI.logger.warn('Search Engine Error:')
+      WIKI.logger.warn(err)
+    }
 
   },
   /**
@@ -37,7 +57,11 @@ module.exports = {
    * @param {Object} page Page to create
    */
   async created(page) {
-    // not used
+    await WIKI.models.knex.raw(`
+      INSERT INTO "pagesVector" (path, locale, title, description, "titleTk", "descriptionTk", "contentTk") VALUES (
+        '?', '?', '?', '?', to_tsvector('?'), to_tsvector('?'), to_tsvector('?')
+      )
+    `, [page.path, page.locale, page.title, page.description, page.title, page.description, page.content])
   },
   /**
    * UPDATE
@@ -45,7 +69,15 @@ module.exports = {
    * @param {Object} page Page to update
    */
   async updated(page) {
-    // not used
+    await WIKI.models.knex.raw(`
+      UPDATE "pagesVector" SET
+        title = '?',
+        description = '?',
+        "titleTk" = to_tsvector('?'),
+        "descriptionTk" = to_tsvector('?'),
+        "contentTk" = to_tsvector('?')
+      WHERE path = '?' AND locale = '?' LIMIT 1
+    `, [page.title, page.description, page.title, page.description, page.content, page.path, page.locale])
   },
   /**
    * DELETE
@@ -53,7 +85,10 @@ module.exports = {
    * @param {Object} page Page to delete
    */
   async deleted(page) {
-    // not used
+    await WIKI.models.knex('pagesVector').where({
+      locale: page.locale,
+      path: page.path
+    }).del().limit(1)
   },
   /**
    * RENAME
@@ -61,12 +96,23 @@ module.exports = {
    * @param {Object} page Page to rename
    */
   async renamed(page) {
-    // not used
+    await WIKI.models.knex('pagesVector').where({
+      locale: page.locale,
+      path: page.sourcePath
+    }).update({
+      locale: page.locale,
+      path: page.destinationPath
+    }).limit(1)
   },
   /**
    * REBUILD INDEX
    */
   async rebuild() {
-    // not used
+    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"
+        FROM "pages"
+        WHERE pages."isPublished" AND NOT pages."isPrivate"`)
   }
 }
-- 
2.24.1