'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} terms * @returns {Promise} 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) } }) } }