search.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import RE2 from 're2';
  2. import { SearchDelegatorName } from '~/interfaces/named-query';
  3. import NamedQuery from '../models/named-query';
  4. import {
  5. SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
  6. } from '../interfaces/search';
  7. import ElasticsearchDelegator from './search-delegator/elasticsearch';
  8. import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
  9. import loggerFactory from '~/utils/logger';
  10. // eslint-disable-next-line no-unused-vars
  11. const logger = loggerFactory('growi:service:search');
  12. const normalizeQueryString = (_queryString: string): string => {
  13. let queryString = _queryString.trim();
  14. queryString = queryString.replace(/\s+/g, ' ');
  15. return queryString;
  16. };
  17. class SearchService implements SearchQueryParser, SearchResolver {
  18. crowi!: any
  19. configManager!: any
  20. isErrorOccuredOnHealthcheck: boolean | null
  21. isErrorOccuredOnSearching: boolean | null
  22. fullTextSearchDelegator: any & SearchDelegator
  23. nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
  24. constructor(crowi) {
  25. this.crowi = crowi;
  26. this.configManager = crowi.configManager;
  27. this.isErrorOccuredOnHealthcheck = null;
  28. this.isErrorOccuredOnSearching = null;
  29. try {
  30. this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
  31. this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
  32. logger.info('Succeeded to initialize search delegators');
  33. }
  34. catch (err) {
  35. logger.error(err);
  36. }
  37. if (this.isConfigured) {
  38. this.fullTextSearchDelegator.init();
  39. this.registerUpdateEvent();
  40. }
  41. }
  42. get isConfigured() {
  43. return this.fullTextSearchDelegator != null;
  44. }
  45. get isReachable() {
  46. return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
  47. }
  48. get isElasticsearchEnabled() {
  49. const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
  50. return uri != null && uri.length > 0;
  51. }
  52. generateFullTextSearchDelegator() {
  53. logger.info('Initializing search delegator');
  54. if (this.isElasticsearchEnabled) {
  55. logger.info('Elasticsearch is enabled');
  56. return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
  57. }
  58. logger.info('No elasticsearch URI is specified so that full text search is disabled.');
  59. }
  60. generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
  61. return {
  62. [SearchDelegatorName.DEFAULT]: defaultDelegator,
  63. [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
  64. };
  65. }
  66. registerUpdateEvent() {
  67. const pageEvent = this.crowi.event('page');
  68. pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
  69. pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
  70. pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPagesDeletedCompletely.bind(this.fullTextSearchDelegator));
  71. pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
  72. pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
  73. pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
  74. const bookmarkEvent = this.crowi.event('bookmark');
  75. bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
  76. bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
  77. const tagEvent = this.crowi.event('tag');
  78. tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
  79. const commentEvent = this.crowi.event('comment');
  80. commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
  81. commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
  82. commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
  83. }
  84. resetErrorStatus() {
  85. this.isErrorOccuredOnHealthcheck = false;
  86. this.isErrorOccuredOnSearching = false;
  87. }
  88. async reconnectClient() {
  89. logger.info('Try to reconnect...');
  90. this.fullTextSearchDelegator.initClient();
  91. try {
  92. await this.getInfoForHealth();
  93. logger.info('Reconnecting succeeded.');
  94. this.resetErrorStatus();
  95. }
  96. catch (err) {
  97. throw err;
  98. }
  99. }
  100. async getInfo() {
  101. try {
  102. return await this.fullTextSearchDelegator.getInfo();
  103. }
  104. catch (err) {
  105. logger.error(err);
  106. throw err;
  107. }
  108. }
  109. async getInfoForHealth() {
  110. try {
  111. const result = await this.fullTextSearchDelegator.getInfoForHealth();
  112. this.isErrorOccuredOnHealthcheck = false;
  113. return result;
  114. }
  115. catch (err) {
  116. logger.error(err);
  117. // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
  118. this.isErrorOccuredOnHealthcheck = true;
  119. throw err;
  120. }
  121. }
  122. async getInfoForAdmin() {
  123. return this.fullTextSearchDelegator.getInfoForAdmin();
  124. }
  125. async normalizeIndices() {
  126. return this.fullTextSearchDelegator.normalizeIndices();
  127. }
  128. async rebuildIndex() {
  129. return this.fullTextSearchDelegator.rebuildIndex();
  130. }
  131. async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
  132. const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
  133. const replaceRegexp = new RE2(/\[nq:|\]/g);
  134. const queryString = normalizeQueryString(_queryString);
  135. // when Normal Query
  136. if (!regexp.test(queryString)) {
  137. return { queryString, terms: this.parseQueryString(queryString) };
  138. }
  139. // when Named Query
  140. const name = queryString.replace(replaceRegexp, '');
  141. const nq = await NamedQuery.findOne({ name });
  142. // will delegate to full-text search
  143. if (nq == null) {
  144. return { queryString, terms: this.parseQueryString(queryString) };
  145. }
  146. const { aliasOf, delegatorName } = nq;
  147. let parsedQuery;
  148. if (aliasOf != null) {
  149. parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
  150. }
  151. if (delegatorName != null) {
  152. parsedQuery = { queryString, delegatorName };
  153. }
  154. return parsedQuery;
  155. }
  156. async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
  157. const { queryString, terms, delegatorName } = parsedQuery;
  158. if (delegatorName != null) {
  159. const nqDelegator = this.nqDelegators[delegatorName];
  160. if (nqDelegator != null) {
  161. return [nqDelegator, null];
  162. }
  163. }
  164. const data = {
  165. queryString,
  166. terms: terms as QueryTerms,
  167. };
  168. return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
  169. }
  170. async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {
  171. let parsedQuery;
  172. // parse
  173. try {
  174. parsedQuery = await this.parseSearchQuery(keyword);
  175. }
  176. catch (err) {
  177. logger.error('Error occurred while parseSearchQuery', err);
  178. throw err;
  179. }
  180. let delegator;
  181. let data;
  182. // resolve
  183. try {
  184. [delegator, data] = await this.resolve(parsedQuery);
  185. }
  186. catch (err) {
  187. logger.error('Error occurred while resolving search delegator', err);
  188. throw err;
  189. }
  190. return delegator.search(data, user, userGroups, searchOpts);
  191. }
  192. parseQueryString(queryString: string): QueryTerms {
  193. // terms
  194. const matchWords: string[] = [];
  195. const notMatchWords: string[] = [];
  196. const phraseWords: string[] = [];
  197. const notPhraseWords: string[] = [];
  198. const prefixPaths: string[] = [];
  199. const notPrefixPaths: string[] = [];
  200. const tags: string[] = [];
  201. const notTags: string[] = [];
  202. // First: Parse phrase keywords
  203. const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
  204. const phrases = queryString.match(phraseRegExp);
  205. if (phrases !== null) {
  206. queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
  207. phrases.forEach((phrase) => {
  208. phrase.trim();
  209. if (phrase.match(/^-/)) {
  210. notPhraseWords.push(phrase.replace(/^-/, ''));
  211. }
  212. else {
  213. phraseWords.push(phrase);
  214. }
  215. });
  216. }
  217. // Second: Parse other keywords (include minus keywords)
  218. queryString.split(' ').forEach((word) => {
  219. if (word === '') {
  220. return;
  221. }
  222. // https://regex101.com/r/pN9XfK/1
  223. const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
  224. // https://regex101.com/r/3qw9FQ/1
  225. const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
  226. if (matchNegative != null) {
  227. if (matchNegative[1] === 'prefix:') {
  228. notPrefixPaths.push(matchNegative[2]);
  229. }
  230. else if (matchNegative[1] === 'tag:') {
  231. notTags.push(matchNegative[2]);
  232. }
  233. else {
  234. notMatchWords.push(matchNegative[2]);
  235. }
  236. }
  237. else if (matchPositive != null) {
  238. if (matchPositive[1] === 'prefix:') {
  239. prefixPaths.push(matchPositive[2]);
  240. }
  241. else if (matchPositive[1] === 'tag:') {
  242. tags.push(matchPositive[2]);
  243. }
  244. else {
  245. matchWords.push(matchPositive[2]);
  246. }
  247. }
  248. });
  249. const terms = {
  250. match: matchWords,
  251. not_match: notMatchWords,
  252. phrase: phraseWords,
  253. not_phrase: notPhraseWords,
  254. prefix: prefixPaths,
  255. not_prefix: notPrefixPaths,
  256. tag: tags,
  257. not_tag: notTags,
  258. };
  259. return terms;
  260. }
  261. }
  262. export default SearchService;