package com.tyndalehouse.step.rest.controllers; import com.tyndalehouse.step.core.models.AbstractComplexSearch; import com.tyndalehouse.step.core.models.LexiconSuggestion; import com.tyndalehouse.step.core.models.SearchToken; import com.tyndalehouse.step.core.models.SingleSuggestionsSummary; import com.tyndalehouse.step.core.models.SuggestionsSummary; import com.tyndalehouse.step.core.models.search.AutoSuggestion; import com.tyndalehouse.step.core.models.search.PopularSuggestion; import com.tyndalehouse.step.core.models.search.SubjectEntries; import com.tyndalehouse.step.core.models.search.SuggestionType; import com.tyndalehouse.step.core.service.BibleInformationService; import com.tyndalehouse.step.core.service.SearchService; import com.tyndalehouse.step.core.service.SuggestionService; import com.tyndalehouse.step.core.service.helpers.SuggestionContext; import com.tyndalehouse.step.core.service.jsword.JSwordPassageService; import com.tyndalehouse.step.core.service.search.OriginalWordSuggestionService; import com.tyndalehouse.step.core.service.search.SubjectEntrySearchService; import com.tyndalehouse.step.core.utils.ConversionUtils; import com.tyndalehouse.step.core.utils.StringUtils; import com.yammer.metrics.annotation.Timed; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; import javax.inject.Singleton; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import static com.tyndalehouse.step.core.exceptions.UserExceptionType.APP_MISSING_FIELD; import static com.tyndalehouse.step.core.utils.StringUtils.isBlank; import static com.tyndalehouse.step.core.utils.ValidateUtils.notBlank; /** * Caters for searching across the data base * * @author chrisburrell */ @Singleton public class SearchController { private static final Pattern SPLIT_TOKENS = Pattern.compile("\\|"); private static final Logger LOGGER = LoggerFactory.getLogger(SearchController.class); private static final String DEFAULT_OPTIONS = "NHVUG"; private final SearchService searchService; private final SuggestionService suggestionService; private final OriginalWordSuggestionService originalWordSuggestions; private final SubjectEntrySearchService subjectEntries; private final BibleInformationService bibleInformationService; /** * @param search the search service * @param originalWordSuggestions the original word suggestions * @param subjectEntries is able to retrieve the search entries */ @Inject public SearchController(final SearchService search, final SuggestionService suggestionService, final OriginalWordSuggestionService originalWordSuggestions, final SubjectEntrySearchService subjectEntries, final BibleInformationService bibleInformationService) { this.searchService = search; this.suggestionService = suggestionService; this.originalWordSuggestions = originalWordSuggestions; this.subjectEntries = subjectEntries; this.bibleInformationService = bibleInformationService; } /** * Suggests options to the user. * * @param input the input from the user */ @Timed(name = "suggest", group = "search", rateUnit = TimeUnit.SECONDS, durationUnit = TimeUnit.MILLISECONDS) public List<AutoSuggestion> suggest(final String input) { return this.suggest(input, null); } /** * Suggests options to the user. * * @param input the user input * @param context any specific user context, such as the selection of a book, or a particular master version already * in the box * @return */ public List<AutoSuggestion> suggest(final String input, final String context) { return suggest(input, context, null); } /** * Suggests options to the user. * * @param input the input from the user * @param context any specific user context, such as the selection of a book, or a particular master version * already in the box * @param referencesOnly true to indicate we only want references back */ @Timed(name = "suggest", group = "search", rateUnit = TimeUnit.SECONDS, durationUnit = TimeUnit.MILLISECONDS) public List<AutoSuggestion> suggest(final String input, final String context, final String referencesOnly) { boolean onlyReferences = false; if (StringUtils.isNotBlank(referencesOnly)) { onlyReferences = Boolean.parseBoolean(referencesOnly); } if (input.indexOf('=') != -1) { return new ArrayList<AutoSuggestion>(); } final List<AutoSuggestion> autoSuggestions = new ArrayList<AutoSuggestion>(128); String bookContext = JSwordPassageService.REFERENCE_BOOK; String referenceContext = null; String limitType = null; boolean exampleData = false; if (StringUtils.isNotBlank(context)) { //there are some context items... Parse them //if there is a reference= restriction, then we will only return references, otherwise, we default final List<SearchToken> searchTokens = parseTokens(context); for (SearchToken st : searchTokens) { if (SearchToken.VERSION.equals(st.getTokenType())) { bookContext = st.getToken(); } else if (SearchToken.REFERENCE.equals(st.getTokenType())) { referenceContext = st.getToken(); } else if (SearchToken.LIMIT.equals(st.getTokenType())) { limitType = st.getToken(); } else if (SearchToken.EXAMPLE_DATA.equals(st.getTokenType())) { exampleData = true; } } } if (onlyReferences || referenceContext != null) { addReferenceSuggestions(limitType, input, autoSuggestions, bookContext, referenceContext); } else { addDefaultSuggestions(input, autoSuggestions, limitType, bookContext, exampleData); } return autoSuggestions; } /** * @param input the input entered by the user so far * @param autoSuggestions the list of suggestions * @param limitType only one type of data is requested * @param referenceBookContext the reference book (i..e master book) that has already been selected by the user. * @param exampleData example data is requested */ private void addDefaultSuggestions(final String input, final List<AutoSuggestion> autoSuggestions, final String limitType, final String referenceBookContext, final boolean exampleData) { SuggestionContext context = new SuggestionContext(); context.setMasterBook(referenceBookContext); context.setInput(StringUtils.trim(input)); context.setSearchType(limitType); context.setExampleData(exampleData); if (exampleData) { convert(autoSuggestions, this.suggestionService.getFirstNSuggestions(context)); } else if (StringUtils.isBlank(limitType)) { // we only return the right set of suggestions if there is a limit type convert(autoSuggestions, this.suggestionService.getTopSuggestions(context)); } else { convert(autoSuggestions, this.suggestionService.getFirstNSuggestions(context)); } } private void convert(final List<AutoSuggestion> autoSuggestions, final SuggestionsSummary topSuggestions) { for (SingleSuggestionsSummary summary : topSuggestions.getSuggestionsSummaries()) { //we render each option final List<? extends PopularSuggestion> popularSuggestions = summary.getPopularSuggestions(); for (PopularSuggestion p : popularSuggestions) { AutoSuggestion au = new AutoSuggestion(); au.setItemType(summary.getSearchType().toString()); au.setSuggestion(p); autoSuggestions.add(au); } if (summary.getMoreResults() > 0 && !SearchToken.REFERENCE.equals(summary.getSearchType())) { AutoSuggestion au = new AutoSuggestion(); au.setItemType(summary.getSearchType().toString()); au.setGrouped(true); au.setCount(summary.getMoreResults()); au.setMaxReached(SuggestionService.MAX_RESULTS_NON_GROUPED <= summary.getMoreResults()); au.setExtraExamples(summary.getExtraExamples()); autoSuggestions.add(au); } } //re-order the greek overflows int lastNTWord = -1; for(int ii = 0; ii < autoSuggestions.size(); ii++) { if(SearchToken.GREEK_MEANINGS.equals(autoSuggestions.get(ii).getItemType())) { lastNTWord = ii; } if(SearchToken.GREEK.equals(autoSuggestions.get(ii).getItemType())) { AutoSuggestion as = autoSuggestions.remove(ii); autoSuggestions.add(lastNTWord + 1, as); lastNTWord++; } } } /** * Adds the references that match the input * * @param limitType limits the types of suggestions to just 1 kind * @param input input from the user * @param autoSuggestions the list of suggestions * @param version the version to use in our lookup * @param bookScope the book for which we are looking up chapters */ private void addReferenceSuggestions(final String limitType, final String input, final List<AutoSuggestion> autoSuggestions, final String version, final String bookScope) { addAutoSuggestions(limitType, SearchToken.REFERENCE, autoSuggestions, bibleInformationService.getBibleBookNames(input, version, bookScope)); } /** * @param items the list of all items */ public AbstractComplexSearch masterSearch(final String items) { return this.masterSearch(items, null, null, null, null, null); } /** * @param items the list of all items * @param options current display options */ public AbstractComplexSearch masterSearch(final String items, final String options) { return this.masterSearch(items, options, null, null, null, null); } /** * @param items the list of all items * @param options current display options * @param display the display options */ public AbstractComplexSearch masterSearch(final String items, final String options, final String display) { return this.masterSearch(items, options, display, null, null, null); } /** * @param items the list of all items * @param options current display options * @param display the display options * @param pageNumber the number of the page that is desired */ public AbstractComplexSearch masterSearch(final String items, final String options, final String display, final String pageNumber) { return this.masterSearch(items, options, display, pageNumber, null, null); } /** * @param items the list of all items * @param options current display options * @param display the display options * @param pageNumber the number of the page that is desired * @param filter the type of filter required on an original word search */ public AbstractComplexSearch masterSearch(final String items, final String options, final String display, final String pageNumber, final String filter) { return this.masterSearch(items, options, display, pageNumber, filter, null, null); } /** * @param items the list of all items * @param options current display options * @param display the display options * @param pageNumber the number of the page that is desired * @param filter the type of filter required on an original word search */ public AbstractComplexSearch masterSearch(final String items, final String options, final String display, final String pageNumber, final String filter, final String sort) { return this.masterSearch(items, options, display, pageNumber, filter, sort, null); } /** * @param items the list of all items * @param options current display options * @param display the display options * @param pageNumber the number of the page that is desired * @param filter the type of filter required on an original word search * @param context the amount of context to add to the verses hit by a search */ @Timed(name = "master-search", group = "search", rateUnit = TimeUnit.SECONDS, durationUnit = TimeUnit.MILLISECONDS) public AbstractComplexSearch masterSearch(final String items, final String options, final String display, final String pageNumber, final String filter, final String sortOrder, final String context) { final List<SearchToken> searchTokens = parseTokens(items); final int page = ConversionUtils.getValidInt(pageNumber, 1); final int searchContext = ConversionUtils.getValidInt(context, 0); return this.searchService.runQuery(searchTokens, getDefaultedOptions(options), display, page, filter, sortOrder, searchContext, items); } /** * Parses a string in the form of a=2|c=1 into a list of search tokens * * @param items * @return */ private List<SearchToken> parseTokens(final String items) { String[] tokens; if (!StringUtils.isBlank(items)) { tokens = SPLIT_TOKENS.split(items); } else { tokens = new String[0]; } List<SearchToken> searchTokens = new ArrayList<SearchToken>(); for (String t : tokens) { int indexOfPrefix = t.indexOf('='); if (indexOfPrefix == -1) { LOGGER.warn("Ignoring item: [{}]", t); continue; } String text = t.substring(indexOfPrefix + 1); searchTokens.add(new SearchToken(t.substring(0, indexOfPrefix), text)); } return searchTokens; } /** * @param options if null, returns the default options * @return the default options for any passage */ private String getDefaultedOptions(final String options) { return StringUtils.isBlank(options) ? DEFAULT_OPTIONS : options; } /** * @param autoSuggestions the current suggestions * @param suggestions the list of all suggestions to add * @param type the type of the items */ private void addAutoSuggestions(final String limitType, final String type, final List<AutoSuggestion> autoSuggestions, final List<? extends PopularSuggestion> suggestions) { if (StringUtils.isNotBlank(limitType) && !limitType.equals(type)) { // we only return the right set of suggestions if there is a limit type return; } //else, we render each option for (Object o : suggestions) { AutoSuggestion au = new AutoSuggestion(); au.setItemType(type); au.setSuggestion(o); autoSuggestions.add(au); } } /** * Obtains a list of suggestions to display to the user * * @param greek true, to indicate Greek * @param form the form input so far * @return a list of suggestions */ @Timed(name = "exact-form-lookup", group = "languages", rateUnit = TimeUnit.SECONDS, durationUnit = TimeUnit.MILLISECONDS) public List<LexiconSuggestion> getExactForms(final String form, final String greek) { notBlank(form, "Blank lexical prefix passed.", APP_MISSING_FIELD); return this.originalWordSuggestions.getExactForms(form, Boolean.parseBoolean(greek)); } /** * @param root the root word * @param fullHeader the header * @param version to be looked up * @return the list of verses for this subject */ public SubjectEntries getSubjectVerses(final String root, final String fullHeader, final String version) { return this.getSubjectVerses(root, fullHeader, version, null, "0"); } /** * @param root the root word * @param fullHeader the header * @param version to be looked up * @return the list of verses for this subject */ public SubjectEntries getSubjectVerses(final String root, final String fullHeader, final String version, final String limitingReference) { return this.getSubjectVerses(root, fullHeader, version, limitingReference, "0"); } /** * @param root the root word * @param fullHeader the header * @param version to be looked up * @param reference the limiting reference * @param context the context to use to expand the references * @return the list of verses for this subject */ @Timed(name = "subject-search-verses", group = "search", rateUnit = TimeUnit.SECONDS, durationUnit = TimeUnit.MILLISECONDS) public SubjectEntries getSubjectVerses(final String root, final String fullHeader, final String version, final String reference, final String context) { return this.subjectEntries.getSubjectVerses(root, fullHeader, version, reference, ConversionUtils.getValidInt(context, 0)); } }