You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

206 lines
4.8 KiB

  1. 'use strict'
  2. const Promise = require('bluebird')
  3. const _ = require('lodash')
  4. const path = require('path')
  5. const searchIndex = require('search-index')
  6. const stopWord = require('stopword')
  7. const streamToPromise = require('stream-to-promise')
  8. module.exports = {
  9. _si: null,
  10. _isReady: false,
  11. /**
  12. * Initialize search index
  13. *
  14. * @return {undefined} Void
  15. */
  16. init () {
  17. let self = this
  18. let dbPath = path.resolve(ROOTPATH, appconfig.paths.data, 'search')
  19. self._isReady = new Promise((resolve, reject) => {
  20. searchIndex({
  21. deletable: true,
  22. fieldedSearch: true,
  23. indexPath: dbPath,
  24. logLevel: 'error',
  25. stopwords: _.get(stopWord, appconfig.lang, [])
  26. }, (err, si) => {
  27. if (err) {
  28. winston.error('[SERVER.Search] Failed to initialize search index.', err)
  29. reject(err)
  30. } else {
  31. self._si = Promise.promisifyAll(si)
  32. self._si.flushAsync().then(() => {
  33. winston.info('[SERVER.Search] Search index flushed and ready.')
  34. resolve(true)
  35. })
  36. }
  37. })
  38. })
  39. return self
  40. },
  41. /**
  42. * Add a document to the index
  43. *
  44. * @param {Object} content Document content
  45. * @return {Promise} Promise of the add operation
  46. */
  47. add (content) {
  48. let self = this
  49. return self._isReady.then(() => {
  50. return self.delete(content._id).then(() => {
  51. return self._si.concurrentAddAsync({
  52. fieldOptions: [{
  53. fieldName: 'entryPath',
  54. searchable: true,
  55. weight: 2
  56. },
  57. {
  58. fieldName: 'title',
  59. nGramLength: [1, 2],
  60. searchable: true,
  61. weight: 3
  62. },
  63. {
  64. fieldName: 'subtitle',
  65. searchable: true,
  66. weight: 1,
  67. storeable: false
  68. },
  69. {
  70. fieldName: 'parent',
  71. searchable: false
  72. },
  73. {
  74. fieldName: 'content',
  75. searchable: true,
  76. weight: 0,
  77. storeable: false
  78. }]
  79. }, [{
  80. entryPath: content._id,
  81. title: content.title,
  82. subtitle: content.subtitle || '',
  83. parent: content.parent || '',
  84. content: content.content || ''
  85. }]).then(() => {
  86. winston.log('verbose', '[SERVER.Search] Entry ' + content._id + ' added/updated to index.')
  87. return true
  88. }).catch((err) => {
  89. winston.error(err)
  90. })
  91. }).catch((err) => {
  92. winston.error(err)
  93. })
  94. })
  95. },
  96. /**
  97. * Delete an entry from the index
  98. *
  99. * @param {String} The entry path
  100. * @return {Promise} Promise of the operation
  101. */
  102. delete (entryPath) {
  103. let self = this
  104. return self._isReady.then(() => {
  105. return streamToPromise(self._si.search({
  106. query: [{
  107. AND: { 'entryPath': [entryPath] }
  108. }]
  109. })).then((results) => {
  110. if (results.totalHits > 0) {
  111. let delIds = _.map(results.hits, 'id')
  112. return self._si.delAsync(delIds)
  113. } else {
  114. return true
  115. }
  116. }).catch((err) => {
  117. if (err.type === 'NotFoundError') {
  118. return true
  119. } else {
  120. winston.error(err)
  121. }
  122. })
  123. })
  124. },
  125. /**
  126. * Flush the index
  127. *
  128. * @returns {Promise} Promise of the flush operation
  129. */
  130. flush () {
  131. let self = this
  132. return self._isReady.then(() => {
  133. return self._si.flushAsync()
  134. })
  135. },
  136. /**
  137. * Search the index
  138. *
  139. * @param {Array<String>} terms
  140. * @returns {Promise<Object>} Hits and suggestions
  141. */
  142. find (terms) {
  143. let self = this
  144. terms = _.chain(terms)
  145. .deburr()
  146. .toLower()
  147. .trim()
  148. .replace(/[^a-z0-9 ]/g, '')
  149. .value()
  150. let arrTerms = _.chain(terms)
  151. .split(' ')
  152. .filter((f) => { return !_.isEmpty(f) })
  153. .value()
  154. return streamToPromise(self._si.search({
  155. query: [{
  156. AND: { '*': arrTerms }
  157. }],
  158. pageSize: 10
  159. })).then((hits) => {
  160. if (hits.length > 0) {
  161. hits = _.map(_.sortBy(hits, ['score']), h => {
  162. return h.document
  163. })
  164. }
  165. if (hits.length < 5) {
  166. return streamToPromise(self._si.match({
  167. beginsWith: terms,
  168. threshold: 3,
  169. limit: 5,
  170. type: 'simple'
  171. })).then((matches) => {
  172. return {
  173. match: hits,
  174. suggest: matches
  175. }
  176. })
  177. } else {
  178. return {
  179. match: hits,
  180. suggest: []
  181. }
  182. }
  183. }).catch((err) => {
  184. if (err.type === 'NotFoundError') {
  185. return {
  186. match: [],
  187. suggest: []
  188. }
  189. } else {
  190. winston.error(err)
  191. }
  192. })
  193. }
  194. }