|
|
@@ -11,6 +11,9 @@ import ElasticsearchDelegator from './search-delegator/elasticsearch';
|
|
|
import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
|
|
|
|
|
|
import loggerFactory from '~/utils/logger';
|
|
|
+import { PageModel } from '../models/page';
|
|
|
+import { serializeUserSecurely } from '../models/serializers/user-serializer';
|
|
|
+import { IPage } from '~/interfaces/page';
|
|
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
|
const logger = loggerFactory('growi:service:search');
|
|
|
@@ -31,6 +34,28 @@ const normalizeQueryString = (_queryString: string): string => {
|
|
|
return queryString;
|
|
|
};
|
|
|
|
|
|
+export type FormattedSearchResult = {
|
|
|
+ data: {
|
|
|
+ pageData: IPage
|
|
|
+ pageMeta: {
|
|
|
+ bookmarkCount?: number
|
|
|
+ elasticsearchResult?: {
|
|
|
+ snippet?: string
|
|
|
+ matchedPath?: string
|
|
|
+ highlightedPath?: string
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }[]
|
|
|
+
|
|
|
+ totalCount: number
|
|
|
+
|
|
|
+ meta: {
|
|
|
+ total: number
|
|
|
+ took?: number
|
|
|
+ count?: number
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
class SearchService implements SearchQueryParser, SearchResolver {
|
|
|
|
|
|
crowi!: any
|
|
|
@@ -228,7 +253,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
|
|
|
return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
|
|
|
}
|
|
|
|
|
|
- async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {
|
|
|
+ async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[Result<any> & MetaData, string]> {
|
|
|
let parsedQuery;
|
|
|
// parse
|
|
|
try {
|
|
|
@@ -250,7 +275,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
|
|
|
throw err;
|
|
|
}
|
|
|
|
|
|
- return delegator.search(data, user, userGroups, searchOpts);
|
|
|
+ return [await delegator.search(data, user, userGroups, searchOpts), delegator.name];
|
|
|
}
|
|
|
|
|
|
parseQueryString(queryString: string): QueryTerms {
|
|
|
@@ -331,22 +356,97 @@ class SearchService implements SearchQueryParser, SearchResolver {
|
|
|
return terms;
|
|
|
}
|
|
|
|
|
|
+ // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
|
|
|
+ // So far, it determines by delegatorName passed by searchService.searchKeyword
|
|
|
+ checkIsFormattable(searchResult, delegatorName): boolean {
|
|
|
+ return delegatorName === SearchDelegatorName.DEFAULT;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* formatting result
|
|
|
*/
|
|
|
- formatResult(esResult) {
|
|
|
- esResult.data.forEach((data) => {
|
|
|
- const highlightData = data._highlight;
|
|
|
- const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
|
|
|
- const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
|
|
|
-
|
|
|
- data.elasticSearchResult = {
|
|
|
- snippet: filterXss.process(snippet),
|
|
|
- // todo: use filter xss.process() for matchedPath;
|
|
|
- matchedPath: pathMatch,
|
|
|
+ async formatSearchResult(searchResult: Result<any> & MetaData, delegatorName): Promise<FormattedSearchResult> {
|
|
|
+ if (!this.checkIsFormattable(searchResult, delegatorName)) {
|
|
|
+ const data = searchResult.data.map((page) => {
|
|
|
+ return {
|
|
|
+ pageData: page,
|
|
|
+ pageMeta: {},
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ data,
|
|
|
+ totalCount: data.length,
|
|
|
+ meta: searchResult.meta,
|
|
|
};
|
|
|
+ }
|
|
|
+
|
|
|
+ /*
|
|
|
+ * Format ElasticSearch result
|
|
|
+ */
|
|
|
+
|
|
|
+ const Page = this.crowi.model('Page') as PageModel;
|
|
|
+ const User = this.crowi.model('User');
|
|
|
+ const result = {} as FormattedSearchResult;
|
|
|
+
|
|
|
+ // create score map for sorting
|
|
|
+ // key: id , value: score
|
|
|
+ const scoreMap = {};
|
|
|
+ for (const esPage of searchResult.data) {
|
|
|
+ scoreMap[esPage._id] = esPage._score;
|
|
|
+ }
|
|
|
+
|
|
|
+ const ids = searchResult.data.map((page) => { return page._id });
|
|
|
+ const findResult = await Page.findListByPageIds(ids);
|
|
|
+
|
|
|
+ // add tags data to page
|
|
|
+ findResult.pages.map((pageData) => {
|
|
|
+ const data = searchResult.data.find((data) => {
|
|
|
+ return pageData.id === data._id;
|
|
|
+ });
|
|
|
+ pageData._doc.tags = data._source.tag_names;
|
|
|
+ return pageData;
|
|
|
});
|
|
|
- return esResult;
|
|
|
+
|
|
|
+ result.meta = searchResult.meta;
|
|
|
+ result.totalCount = findResult.totalCount;
|
|
|
+ result.data = findResult.pages
|
|
|
+ .map((pageData) => {
|
|
|
+ if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
|
|
|
+ pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = searchResult.data.find((data) => {
|
|
|
+ return pageData.id === data._id;
|
|
|
+ });
|
|
|
+
|
|
|
+ // increment elasticSearchResult
|
|
|
+ let elasticSearchResult;
|
|
|
+ const highlightData = data._highlight;
|
|
|
+ if (highlightData != null) {
|
|
|
+ const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
|
|
|
+ const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
|
|
|
+
|
|
|
+ elasticSearchResult = {
|
|
|
+ snippet: filterXss.process(snippet),
|
|
|
+ // todo: use filter xss.process() for matchedPath;
|
|
|
+ matchedPath: pathMatch,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const pageMeta = {
|
|
|
+ bookmarkCount: data._source.bookmark_count || 0,
|
|
|
+ elasticSearchResult,
|
|
|
+ };
|
|
|
+
|
|
|
+ return { pageData, pageMeta };
|
|
|
+ })
|
|
|
+ .sort((page1, page2) => {
|
|
|
+ // note: this do not consider NaN
|
|
|
+ return scoreMap[page2.pageData._id] - scoreMap[page1.pageData._id];
|
|
|
+ });
|
|
|
+
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
}
|