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