|
@@ -0,0 +1,206 @@
|
|
|
+'use strict'
|
|
|
+
|
|
|
+const Promise = require('bluebird')
|
|
|
+const _ = require('lodash')
|
|
|
+const path = require('path')
|
|
|
+const searchIndex = require('search-index')
|
|
|
+const stopWord = require('stopword')
|
|
|
+const streamToPromise = require('stream-to-promise')
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+
|
|
|
+ _si: null,
|
|
|
+ _isReady: false,
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Initialize search index
|
|
|
+ *
|
|
|
+ * @return {undefined} Void
|
|
|
+ */
|
|
|
+ init () {
|
|
|
+ let self = this
|
|
|
+ let dbPath = path.resolve(ROOTPATH, appconfig.paths.data, 'search')
|
|
|
+ self._isReady = new Promise((resolve, reject) => {
|
|
|
+ searchIndex({
|
|
|
+ deletable: true,
|
|
|
+ fieldedSearch: true,
|
|
|
+ indexPath: dbPath,
|
|
|
+ logLevel: 'error',
|
|
|
+ stopwords: _.get(stopWord, appconfig.lang, [])
|
|
|
+ }, (err, si) => {
|
|
|
+ if (err) {
|
|
|
+ winston.error('[SERVER.Search] Failed to initialize search index.', err)
|
|
|
+ reject(err)
|
|
|
+ } else {
|
|
|
+ self._si = Promise.promisifyAll(si)
|
|
|
+ self._si.flushAsync().then(() => {
|
|
|
+ winston.info('[SERVER.Search] Search index flushed and ready.')
|
|
|
+ resolve(true)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ return self
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Add a document to the index
|
|
|
+ *
|
|
|
+ * @param {Object} content Document content
|
|
|
+ * @return {Promise} Promise of the add operation
|
|
|
+ */
|
|
|
+ add (content) {
|
|
|
+ let self = this
|
|
|
+
|
|
|
+ return self._isReady.then(() => {
|
|
|
+ return self.delete(content._id).then(() => {
|
|
|
+ return self._si.concurrentAddAsync({
|
|
|
+ fieldOptions: [{
|
|
|
+ fieldName: 'entryPath',
|
|
|
+ searchable: true,
|
|
|
+ weight: 2
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fieldName: 'title',
|
|
|
+ nGramLength: [1, 2],
|
|
|
+ searchable: true,
|
|
|
+ weight: 3
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fieldName: 'subtitle',
|
|
|
+ searchable: true,
|
|
|
+ weight: 1,
|
|
|
+ storeable: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fieldName: 'parent',
|
|
|
+ searchable: false
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fieldName: 'content',
|
|
|
+ searchable: true,
|
|
|
+ weight: 0,
|
|
|
+ storeable: false
|
|
|
+ }]
|
|
|
+ }, [{
|
|
|
+ entryPath: content._id,
|
|
|
+ title: content.title,
|
|
|
+ subtitle: content.subtitle || '',
|
|
|
+ parent: content.parent || '',
|
|
|
+ content: content.content || ''
|
|
|
+ }]).then(() => {
|
|
|
+ winston.log('verbose', '[SERVER.Search] Entry ' + content._id + ' added/updated to index.')
|
|
|
+ return true
|
|
|
+ }).catch((err) => {
|
|
|
+ winston.error(err)
|
|
|
+ })
|
|
|
+ }).catch((err) => {
|
|
|
+ winston.error(err)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Delete an entry from the index
|
|
|
+ *
|
|
|
+ * @param {String} The entry path
|
|
|
+ * @return {Promise} Promise of the operation
|
|
|
+ */
|
|
|
+ delete (entryPath) {
|
|
|
+ let self = this
|
|
|
+
|
|
|
+ return self._isReady.then(() => {
|
|
|
+ return streamToPromise(self._si.search({
|
|
|
+ query: [{
|
|
|
+ AND: { 'entryPath': [entryPath] }
|
|
|
+ }]
|
|
|
+ })).then((results) => {
|
|
|
+ if (results.totalHits > 0) {
|
|
|
+ let delIds = _.map(results.hits, 'id')
|
|
|
+ return self._si.delAsync(delIds)
|
|
|
+ } else {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }).catch((err) => {
|
|
|
+ if (err.type === 'NotFoundError') {
|
|
|
+ return true
|
|
|
+ } else {
|
|
|
+ winston.error(err)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Flush the index
|
|
|
+ *
|
|
|
+ * @returns {Promise} Promise of the flush operation
|
|
|
+ */
|
|
|
+ flush () {
|
|
|
+ let self = this
|
|
|
+ return self._isReady.then(() => {
|
|
|
+ return self._si.flushAsync()
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Search the index
|
|
|
+ *
|
|
|
+ * @param {Array<String>} terms
|
|
|
+ * @returns {Promise<Object>} Hits and suggestions
|
|
|
+ */
|
|
|
+ find (terms) {
|
|
|
+ let self = this
|
|
|
+ terms = _.chain(terms)
|
|
|
+ .deburr()
|
|
|
+ .toLower()
|
|
|
+ .trim()
|
|
|
+ .replace(/[^a-z0-9 ]/g, '')
|
|
|
+ .value()
|
|
|
+ let arrTerms = _.chain(terms)
|
|
|
+ .split(' ')
|
|
|
+ .filter((f) => { return !_.isEmpty(f) })
|
|
|
+ .value()
|
|
|
+
|
|
|
+ return streamToPromise(self._si.search({
|
|
|
+ query: [{
|
|
|
+ AND: { '*': arrTerms }
|
|
|
+ }],
|
|
|
+ pageSize: 10
|
|
|
+ })).then((hits) => {
|
|
|
+ if (hits.length > 0) {
|
|
|
+ hits = _.map(_.sortBy(hits, ['score']), h => {
|
|
|
+ return h.document
|
|
|
+ })
|
|
|
+ }
|
|
|
+ if (hits.length < 5) {
|
|
|
+ return streamToPromise(self._si.match({
|
|
|
+ beginsWith: terms,
|
|
|
+ threshold: 3,
|
|
|
+ limit: 5,
|
|
|
+ type: 'simple'
|
|
|
+ })).then((matches) => {
|
|
|
+ return {
|
|
|
+ match: hits,
|
|
|
+ suggest: matches
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ return {
|
|
|
+ match: hits,
|
|
|
+ suggest: []
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }).catch((err) => {
|
|
|
+ if (err.type === 'NotFoundError') {
|
|
|
+ return {
|
|
|
+ match: [],
|
|
|
+ suggest: []
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ winston.error(err)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|