|
|
|
'use strict'
|
|
|
|
|
|
|
|
/* global winston */
|
|
|
|
|
|
|
|
const Promise = require('bluebird')
|
|
|
|
const _ = require('lodash')
|
|
|
|
const searchIndex = require('./search-index')
|
|
|
|
const stopWord = require('stopword')
|
|
|
|
const streamToPromise = require('stream-to-promise')
|
|
|
|
const searchAllowedChars = new RegExp('[^a-z0-9' + appdata.regex.cjk + appdata.regex.arabic + ' ]', 'g')
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
|
|
_si: null,
|
|
|
|
_isReady: false,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize search index
|
|
|
|
*
|
|
|
|
* @return {undefined} Void
|
|
|
|
*/
|
|
|
|
init () {
|
|
|
|
let self = this
|
|
|
|
self._isReady = new Promise((resolve, reject) => {
|
|
|
|
searchIndex({
|
|
|
|
deletable: true,
|
|
|
|
fieldedSearch: true,
|
|
|
|
indexPath: 'wiki',
|
|
|
|
logLevel: 'error',
|
|
|
|
stopwords: _.get(stopWord, appconfig.lang, [])
|
|
|
|
}, (err, si) => {
|
|
|
|
if (err) {
|
|
|
|
winston.error('Failed to initialize search index.', err)
|
|
|
|
reject(err)
|
|
|
|
} else {
|
|
|
|
self._si = Promise.promisifyAll(si)
|
|
|
|
self._si.flushAsync().then(() => {
|
|
|
|
winston.info('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
|
|
|
|
|
|
|
|
if (!content.isEntry) {
|
|
|
|
return Promise.resolve(true)
|
|
|
|
}
|
|
|
|
|
|
|
|
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.text || ''
|
|
|
|
}]).then(() => {
|
|
|
|
winston.log('verbose', 'Entry ' + content._id + ' added/updated to search 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 && results.length > 0) {
|
|
|
|
let delIds = _.map(results, '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(searchAllowedChars, ' ')
|
|
|
|
.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)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|