diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e6151b0..6171624d 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue index 8f4aa819..bd2372ce 100644 --- a/client/components/common/nav-header.vue +++ b/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 diff --git a/client/components/common/search-results.vue b/client/components/common/search-results.vue index c20eafdc..9a021fa8 100644 --- a/client/components/common/search-results.vue +++ b/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 { diff --git a/server/modules/search/postgres/engine.js b/server/modules/search/postgres/engine.js index 6885a443..e15d42f8 100644 --- a/server/modules/search/postgres/engine.js +++ b/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"`) }