/**
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright ownership. Apereo
* licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
* this file except in compliance with the License. You may obtain a copy of the License at the
* following location:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apereo.portal.portlets.search;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.portlet.ActionRequest;
import javax.portlet.ActionResponse;
import javax.portlet.Event;
import javax.portlet.EventRequest;
import javax.portlet.EventResponse;
import javax.portlet.PortletPreferences;
import javax.portlet.PortletRequest;
import javax.portlet.PortletSession;
import javax.portlet.RenderRequest;
import javax.portlet.RenderResponse;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang.RandomStringUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.Validate;
import org.apereo.portal.portlet.PortletUtils;
import org.apereo.portal.portlet.om.IPortletDefinition;
import org.apereo.portal.portlet.om.IPortletEntity;
import org.apereo.portal.portlet.om.IPortletWindow;
import org.apereo.portal.portlet.om.IPortletWindowId;
import org.apereo.portal.portlet.registry.IPortletWindowRegistry;
import org.apereo.portal.portlet.rendering.IPortletRenderer;
import org.apereo.portal.search.PortletUrl;
import org.apereo.portal.search.PortletUrlParameter;
import org.apereo.portal.search.PortletUrlType;
import org.apereo.portal.search.SearchConstants;
import org.apereo.portal.search.SearchRequest;
import org.apereo.portal.search.SearchResult;
import org.apereo.portal.search.SearchResults;
import org.apereo.portal.spring.spel.IPortalSpELService;
import org.apereo.portal.url.IPortalRequestUtils;
import org.apereo.portal.url.IPortalUrlBuilder;
import org.apereo.portal.url.IPortalUrlProvider;
import org.apereo.portal.url.IPortletUrlBuilder;
import org.apereo.portal.url.UrlType;
import org.apereo.portal.utils.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.SpelParseException;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.portlet.ModelAndView;
import org.springframework.web.portlet.bind.annotation.ActionMapping;
import org.springframework.web.portlet.bind.annotation.EventMapping;
import org.springframework.web.portlet.bind.annotation.ResourceMapping;
/**
* SearchPortletController produces both a search form and results for configured search services.
*
*/
@Controller
@RequestMapping("VIEW")
public class SearchPortletController {
private static final String SEARCH_RESULTS_CACHE_NAME =
SearchPortletController.class.getName() + ".searchResultsCache";
private static final String SEARCH_COUNTER_NAME =
SearchPortletController.class.getName() + ".searchCounter";
private static final String SEARCH_HANDLED_CACHE_NAME =
SearchPortletController.class.getName() + ".searchHandledCache";
private static final String SEARCH_LAST_QUERY_ID =
SearchPortletController.class.getName() + ".searchLastQueryId";
private static final String AJAX_MAX_QUERIES_URL =
"/scripts/search/hitMaxQueriesGive404Result.json"; // URL to nonexistent file
private static final String SEARCH_LAUNCH_FNAME = "searchLaunchFname";
private static final String AJAX_RESPONSE_RESOURCE_ID = "retrieveSearchJSONResults";
private static final List<String> UNDEFINED_SEARCH_RESULT_TYPE =
Arrays.asList(new String[] {"UndefinedResultType"});
private static final String AUTOCOMPLETE_MAX_TEXT_LENGTH_PREF_NAME = "autocompleteMaxTextSize";
protected final Logger logger = LoggerFactory.getLogger(getClass());
private IPortalUrlProvider portalUrlProvider;
private IPortletWindowRegistry portletWindowRegistry;
private IPortalRequestUtils portalRequestUtils;
private List<IPortalSearchService> searchServices;
// Map from result-type -> Set<tab-key>
private Map<String, Set<String>> resultTypeMappings = Collections.emptyMap();
private List<String> tabKeys = Collections.emptyList();
private String defaultTabKey = "portal.results";
private int maximumSearchesPerMinute = 18;
private int maxAutocompleteSearchResults = 10;
// Map of (search result type, priority) to prioritize search autocomplete results. 0 is default priority.
// > 0 is higher priority, < 0 is lower priority.
private Map<String, Integer> autocompleteResultTypeToPriorityMap = new HashMap<>();
private IPortalSpELService spELService;
// TODO: It would be better to revise the search event to have a set of ignored types and expect the
// search event listeners to voluntarily ignore the search event if they are one of the ignored types (and
// again filtering here in case the search event listener doesn't respect the ignore set).
// This requires changing the SearchEvent and unfortunately there is not time for that now.
private Set<String> autocompleteIgnoreResultTypes = new HashSet<>();
@Resource(name = "searchServices")
public void setPortalSearchServices(List<IPortalSearchService> searchServices) {
this.searchServices = searchServices;
}
/** The messages property key to use for the default results tab */
@Value(
"${org.apereo.portal.portlets.searchSearchPortletController.defaultTabKey:portal.results}")
public void setDefaultTabKey(String defaultTabKey) {
this.defaultTabKey = defaultTabKey;
}
/** Set the maximum number of a user can execute per minute */
@Value(
"${org.apereo.portal.portlets.searchSearchPortletController.maximumSearchesPerMinute:18}")
public void setMaximumSearchesPerMinute(int maximumSearchesPerMinute) {
this.maximumSearchesPerMinute = maximumSearchesPerMinute;
}
public int getMaxAutocompleteSearchResults() {
return maxAutocompleteSearchResults;
}
/** Set the maximum number of autocomplete results for a search */
@Value(
"${org.apereo.portal.portlets.searchSearchPortletController.autocompleteSearchResults:10}")
public void setMaxAutocompleteSearchResults(int maxAutocompleteSearchResults) {
this.maxAutocompleteSearchResults = maxAutocompleteSearchResults;
}
public Map<String, Integer> getAutocompleteResultTypeToPriorityMap() {
return autocompleteResultTypeToPriorityMap;
}
@Resource(name = "searchAutocompletePriorityMap")
public void setAutocompleteResultTypeToPriorityMap(
Map<String, Integer> autocompleteResultTypeToPriorityMap) {
this.autocompleteResultTypeToPriorityMap = autocompleteResultTypeToPriorityMap;
}
@Resource(name = "searchAutocompleteIgnoreResultTypes")
public void setAutocompleteIgnoreResultTypes(Set<String> autocompleteIgnoreResultTypes) {
this.autocompleteIgnoreResultTypes = autocompleteIgnoreResultTypes;
}
@Autowired
@Qualifier(value = "portalSpELServiceImpl")
public void setSpELService(IPortalSpELService spELService) {
this.spELService = spELService;
}
/**
* Set the mappings from TabKey to ResultType. The map keys must be strings but the values can
* be either String or Collection<String>
*/
@SuppressWarnings("unchecked")
@Resource(name = "searchTabs")
//Map of tab-key to string or collection<string> of search result types
public void setSearchTabs(Map<String, Object> searchTabMappings) {
final Map<String, Set<String>> resultTypeMappingsBuilder =
new LinkedHashMap<>();
final List<String> tabKeysBuilder = new ArrayList<>(searchTabMappings.size());
for (final Map.Entry<String, Object> tabMapping : searchTabMappings.entrySet()) {
final String tabKey = tabMapping.getKey();
tabKeysBuilder.add(tabKey);
final Object resultTypes = tabMapping.getValue();
if (resultTypes instanceof Collection) {
for (final String resultType : (Collection<String>) resultTypes) {
addTabKey(resultTypeMappingsBuilder, tabKey, resultType);
}
} else {
final String resultType = (String) resultTypes;
addTabKey(resultTypeMappingsBuilder, tabKey, resultType);
}
}
this.resultTypeMappings = resultTypeMappingsBuilder;
this.tabKeys = tabKeysBuilder;
}
protected void addTabKey(
final Map<String, Set<String>> resultTypeMappingsBuilder,
final String tabKey,
final String resultType) {
Set<String> tabKeys = resultTypeMappingsBuilder.get(resultType);
if (tabKeys == null) {
tabKeys = new LinkedHashSet<>();
resultTypeMappingsBuilder.put(resultType, tabKeys);
}
tabKeys.add(tabKey);
}
@Autowired
public void setPortalUrlProvider(IPortalUrlProvider urlProvider) {
this.portalUrlProvider = urlProvider;
}
@Autowired
public void setPortletWindowRegistry(IPortletWindowRegistry portletWindowRegistry) {
this.portletWindowRegistry = portletWindowRegistry;
}
@Autowired
public void setPortalRequestUtils(IPortalRequestUtils portalRequestUtils) {
Validate.notNull(portalRequestUtils);
this.portalRequestUtils = portalRequestUtils;
}
@SuppressWarnings("unchecked")
@ActionMapping
public void performSearch(
@RequestParam(value = "query") String query,
ActionRequest request,
ActionResponse response,
@RequestParam(value = "ajax", required = false) final boolean ajax)
throws IOException {
final PortletSession session = request.getPortletSession();
final String queryId = RandomStringUtils.randomAlphanumeric(32);
Cache<String, Boolean> searchCounterCache;
synchronized (org.springframework.web.portlet.util.PortletUtils.getSessionMutex(session)) {
searchCounterCache = (Cache<String, Boolean>) session.getAttribute(SEARCH_COUNTER_NAME);
if (searchCounterCache == null) {
searchCounterCache =
CacheBuilder.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
session.setAttribute(SEARCH_COUNTER_NAME, searchCounterCache);
}
}
//Store the query id to track number of searches/minute
searchCounterCache.put(queryId, Boolean.TRUE);
if (searchCounterCache.size() > this.maximumSearchesPerMinute) {
//Make sure old data is expired
searchCounterCache.cleanUp();
//Too many searches in the last minute, fail the search
if (searchCounterCache.size() > this.maximumSearchesPerMinute) {
logger.debug(
"Rejecting search for '{}', exceeded max queries per minute for user",
query);
if (!ajax) {
response.setRenderParameter("hitMaxQueries", Boolean.TRUE.toString());
response.setRenderParameter("query", query);
} else {
// For Ajax return to a nonexistent file to generate the 404 error since it was easier for the
// UI to have an error response.
final String contextPath = request.getContextPath();
response.sendRedirect(contextPath + AJAX_MAX_QUERIES_URL);
}
return;
}
}
// construct a new search query object from the string query
final SearchRequest queryObj = new SearchRequest();
queryObj.setQueryId(queryId);
queryObj.setSearchTerms(query);
// Create the session-shared results object
final PortalSearchResults results =
new PortalSearchResults(defaultTabKey, resultTypeMappings);
// place the portal search results object in the session using the queryId to namespace it
Cache<String, PortalSearchResults> searchResultsCache;
synchronized (org.springframework.web.portlet.util.PortletUtils.getSessionMutex(session)) {
searchResultsCache =
(Cache<String, PortalSearchResults>)
session.getAttribute(SEARCH_RESULTS_CACHE_NAME);
if (searchResultsCache == null) {
searchResultsCache =
CacheBuilder.newBuilder()
.maximumSize(20)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build();
session.setAttribute(SEARCH_RESULTS_CACHE_NAME, searchResultsCache);
}
// Save the last queryId for an ajax autocomplete search response.
session.setAttribute(SEARCH_LAST_QUERY_ID, queryId);
}
searchResultsCache.put(queryId, results);
/*
* TODO: For autocomplete I wish we didn't have to go through a whole render phase just
* to trigger the events-based features of the portlet, but atm I don't
* see a way around it, since..
*
* - (1) You can only start an event chain in the Action phase; and
* - (2) You can only return JSON in a Resource phase; and
* - (3) An un-redirected Action phase leads to a Render phase, not a
* Resource phase :(
*
* It would be awesome either (first choice) to do Action > Event > Resource,
* or Action > sendRedirect() followed by a Resource request.
*
* As it stands, this implementation will trigger a complete render on
* the portal needlessly.
*/
// send a search query event
response.setEvent(SearchConstants.SEARCH_REQUEST_QNAME, queryObj);
logger.debug("Query initiated for queryId {}, query {}", queryId, query);
response.setRenderParameter("queryId", queryId);
response.setRenderParameter("query", query);
}
/**
* Performs a search of the explicitly configured {@link IPortalSearchService}s. This is done as
* an event handler so that it can run concurrently with the other portlets handling the search
* request
*/
@SuppressWarnings("unchecked")
@EventMapping(SearchConstants.SEARCH_REQUEST_QNAME_STRING)
public void handleSearchRequest(EventRequest request, EventResponse response) {
// UP-3887 Design flaw. Both the searchLauncher portlet instance and the search portlet instance receive
// searchRequest and searchResult events because they are in the same portlet code base (to share
// autosuggest_handler.jsp and because we have to calculate the search portlet url for the ajax call)
// and share the portlet.xml which defines the event handling behavior.
// If this instance is the searchLauncher, ignore the searchResult. The search was submitted to the search
// portlet instance.
final String searchLaunchFname =
request.getPreferences().getValue(SEARCH_LAUNCH_FNAME, null);
if (searchLaunchFname != null) {
// Noisy in debug mode so commented out log statement
// logger.debug("SearchLauncher does not participate in SearchRequest events so discarding message");
return;
}
final Event event = request.getEvent();
final SearchRequest searchQuery = (SearchRequest) event.getValue();
//Map used to track searches that have been handled, used so that one search doesn't get duplicate results
ConcurrentMap<String, Boolean> searchHandledCache;
final PortletSession session = request.getPortletSession();
synchronized (org.springframework.web.portlet.util.PortletUtils.getSessionMutex(session)) {
searchHandledCache =
(ConcurrentMap<String, Boolean>)
session.getAttribute(
SEARCH_HANDLED_CACHE_NAME, PortletSession.APPLICATION_SCOPE);
if (searchHandledCache == null) {
searchHandledCache =
CacheBuilder.newBuilder()
.maximumSize(20)
.expireAfterAccess(5, TimeUnit.MINUTES)
.<String, Boolean>build()
.asMap();
session.setAttribute(
SEARCH_HANDLED_CACHE_NAME,
searchHandledCache,
PortletSession.APPLICATION_SCOPE);
}
}
final String queryId = searchQuery.getQueryId();
if (searchHandledCache.putIfAbsent(queryId, Boolean.TRUE) != null) {
//Already handled this search request
return;
}
//Create the results
final SearchResults results = new SearchResults();
results.setQueryId(queryId);
results.setWindowId(request.getWindowID());
final List<SearchResult> searchResultList = results.getSearchResult();
//Run the search for each service appending the results
for (IPortalSearchService searchService : searchServices) {
try {
logger.debug(
"For queryId {}, query '{}', searching search service {}",
queryId,
searchQuery.getSearchTerms(),
searchService.getClass().toString());
final SearchResults serviceResults =
searchService.getSearchResults(request, searchQuery);
logger.debug(
"For queryId {}, obtained {} results from search service {}",
queryId,
serviceResults.getSearchResult().size(),
searchService.getClass().toString());
searchResultList.addAll(serviceResults.getSearchResult());
} catch (Exception e) {
logger.warn(
searchService.getClass()
+ " threw an exception when searching, it will be ignored. "
+ searchQuery,
e);
}
}
//Respond with a results event if results were found
if (!searchResultList.isEmpty()) {
response.setEvent(SearchConstants.SEARCH_RESULTS_QNAME, results);
}
}
/** Handles all the SearchResults events coming back from portlets */
@EventMapping(SearchConstants.SEARCH_RESULTS_QNAME_STRING)
public void handleSearchResult(EventRequest request) {
// UP-3887 Design flaw. Both the searchLauncher portlet instance and the search portlet instance receive
// searchRequest and searchResult events because they are in the same portlet code base (to share
// autosuggest_handler.jsp and because we have to calculate the search portlet url for the ajax call)
// and share the portlet.xml which defines the event handling behavior.
// If this instance is the searchLauncher, ignore the searchResult. The search was submitted to the search
// portlet instance.
final String searchLaunchFname =
request.getPreferences().getValue(SEARCH_LAUNCH_FNAME, null);
if (searchLaunchFname != null) {
// Noisy in debug mode so commenting out debug message
// logger.debug("SearchLauncher does not process SearchResponse events so discarding message");
return;
}
final Event event = request.getEvent();
final SearchResults portletSearchResults = (SearchResults) event.getValue();
// get the existing portal search result from the session and append
// the results for this event
final String queryId = portletSearchResults.getQueryId();
final PortalSearchResults results = this.getPortalSearchResults(request, queryId);
if (results == null) {
this.logger.warn(
"No PortalSearchResults found for queryId {}, ignoring search results from {}",
queryId,
getSearchResultsSource(portletSearchResults));
return;
}
if (logger.isDebugEnabled()) {
logger.debug(
"For queryId {}, adding {} search results from {}",
queryId,
portletSearchResults.getSearchResult().size(),
getSearchResultsSource(portletSearchResults));
}
final String windowId = portletSearchResults.getWindowId();
final HttpServletRequest httpServletRequest =
this.portalRequestUtils.getPortletHttpRequest(request);
final IPortletWindowId portletWindowId =
this.portletWindowRegistry.getPortletWindowId(httpServletRequest, windowId);
//Add the other portlet's results to the main search results object
this.addSearchResults(portletSearchResults, results, httpServletRequest, portletWindowId);
}
/**
* Return the first search source (type string) in the result, else the string 'unknown'.
*
* @param portletSearchResults Search results
* @return String identifying the search result source if it was populated, else 'unknown'
*/
private String getSearchResultsSource(SearchResults portletSearchResults) {
// Return the first source in the result.
List<SearchResult> portletResults = portletSearchResults.getSearchResult();
String source = "unknown";
if (portletResults.size() > 0
&& portletResults.get(0).getType() != null
&& !portletResults.get(0).getType().isEmpty()) {
source = portletResults.get(0).getType().get(0);
}
return source;
}
/** Display a search form */
@RequestMapping
public ModelAndView showSearchForm(RenderRequest request, RenderResponse response) {
final Map<String, Object> model = new HashMap<>();
// Determine if this portlet displays the search launch view or regular search view.
PortletPreferences prefs = request.getPreferences();
final boolean isMobile = isMobile(request);
String viewName = isMobile ? "/jsp/Search/mobileSearch" : "/jsp/Search/search";
// If this search portlet is configured to be the searchLauncher, calculate the URLs to the indicated
// search portlet.
final String searchLaunchFname = prefs.getValue(SEARCH_LAUNCH_FNAME, null);
if (searchLaunchFname != null) {
model.put("searchLaunchUrl", calculateSearchLaunchUrl(request, response));
model.put("autocompleteUrl", calculateAutocompleteResourceUrl(request, response));
viewName = "/jsp/Search/searchLauncher";
}
return new ModelAndView(viewName, model);
}
/**
* Create an actionUrl for the indicated portlet The resource URL is for the ajax typing search
* results response.
*
* @param request render request
* @param response render response
*/
private String calculateSearchLaunchUrl(RenderRequest request, RenderResponse response) {
final HttpServletRequest httpRequest =
this.portalRequestUtils.getPortletHttpRequest(request);
final IPortalUrlBuilder portalUrlBuilder =
this.portalUrlProvider.getPortalUrlBuilderByPortletFName(
httpRequest, "search", UrlType.ACTION);
return portalUrlBuilder.getUrlString();
}
/**
* Create a resourceUrl for <code>AJAX_RESPONSE_RESOURCE_ID</code>. The resource URL is for the
* ajax typing search results response.
*
* @param request render request
* @param response render response
*/
private String calculateAutocompleteResourceUrl(
RenderRequest request, RenderResponse response) {
final HttpServletRequest httpRequest =
this.portalRequestUtils.getPortletHttpRequest(request);
final IPortalUrlBuilder portalUrlBuilder =
this.portalUrlProvider.getPortalUrlBuilderByPortletFName(
httpRequest, "search", UrlType.RESOURCE);
final IPortletUrlBuilder portletUrlBuilder =
portalUrlBuilder.getPortletUrlBuilder(portalUrlBuilder.getTargetPortletWindowId());
portletUrlBuilder.setResourceId(AJAX_RESPONSE_RESOURCE_ID);
return portletUrlBuilder.getPortalUrlBuilder().getUrlString();
}
/** Display search results */
@RequestMapping(params = {"query", "queryId"})
public ModelAndView showSearchResults(
PortletRequest request,
@RequestParam(value = "query") String query,
@RequestParam(value = "queryId") String queryId) {
final Map<String, Object> model = new HashMap<>();
model.put("query", query);
ConcurrentMap<String, List<Tuple<SearchResult, String>>> results =
new ConcurrentHashMap<>();
final PortalSearchResults portalSearchResults =
this.getPortalSearchResults(request, queryId);
if (portalSearchResults != null) {
results = portalSearchResults.getResults();
}
model.put("results", results);
model.put("defaultTabKey", this.defaultTabKey);
model.put("tabKeys", this.tabKeys);
final boolean isMobile = isMobile(request);
String viewName = isMobile ? "/jsp/Search/mobileSearch" : "/jsp/Search/search";
return new ModelAndView(viewName, model);
}
/** Display search results */
@RequestMapping(params = {"query", "hitMaxQueries"})
public ModelAndView showSearchError(
PortletRequest request,
@RequestParam(value = "query") String query,
@RequestParam(value = "hitMaxQueries") boolean hitMaxQueries) {
final Map<String, Object> model = new HashMap<>();
model.put("query", query);
model.put("hitMaxQueries", hitMaxQueries);
final boolean isMobile = isMobile(request);
String viewName = isMobile ? "/jsp/Search/mobileSearch" : "/jsp/Search/search";
return new ModelAndView(viewName, model);
}
/** Display AJAX autocomplete search results for the last query */
@ResourceMapping(value = "retrieveSearchJSONResults")
public ModelAndView showJSONSearchResults(PortletRequest request) {
PortletPreferences prefs = request.getPreferences();
int maxTextLength =
Integer.parseInt(prefs.getValue(AUTOCOMPLETE_MAX_TEXT_LENGTH_PREF_NAME, "180"));
final Map<String, Object> model = new HashMap<>();
List<AutocompleteResultsModel> results = new ArrayList<>();
final PortletSession session = request.getPortletSession();
String queryId = (String) session.getAttribute(SEARCH_LAST_QUERY_ID);
if (queryId != null) {
final PortalSearchResults portalSearchResults =
this.getPortalSearchResults(request, queryId);
if (portalSearchResults != null) {
final ConcurrentMap<String, List<Tuple<SearchResult, String>>> resultsMap =
portalSearchResults.getResults();
results = collateResultsForAutoCompleteResponse(resultsMap, maxTextLength);
}
}
model.put("results", results);
model.put("count", results.size());
return new ModelAndView("json", model);
}
/**
* Accepts a map (tab name, List of tuple search results) and returns a prioritized list of
* results for the ajax autocomplete feature. The computing impact of moving the list items
* around should be fairly small since there are not too many search results (unless we get a
* lot of search providers, in which case the results could be put into the appropriate format
* in the Event SEARCH_RESULTS_QNAME_STRING handling).
*
* <p>Note that the method (as well as the SearchResults Event handler) do not impose a
* consistent ordering on results. The results are ordered by priority, but within a particular
* priority the same search may have results ordered differently based upon when the
* SearchResults Event handler receives the search results event list. Also if a search result
* is in multiple category types, even within the same priority, the search result will show up
* multiple times. Currently all results are in a single category so it is not worth adding
* extra complexity to handle a situation that is not present.
*
* <p>This method also cleans up and trims down the amount of data shipped so the feature is
* more responsive, especially on mobile networks.
*
* @param resultsMap
* @return
*/
private List<AutocompleteResultsModel> collateResultsForAutoCompleteResponse(
ConcurrentMap<String, List<Tuple<SearchResult, String>>> resultsMap,
int maxTextLength) {
SortedMap<Integer, List<AutocompleteResultsModel>> prioritizedResultsMap =
getCleanedAndSortedMapResults(resultsMap, maxTextLength);
// Consolidate the results into a single, ordered list of max entries.
List<AutocompleteResultsModel> results = new ArrayList<>();
for (List<AutocompleteResultsModel> items : prioritizedResultsMap.values()) {
results.addAll(items);
if (results.size() >= maxAutocompleteSearchResults) {
break;
}
}
return results.subList(
0,
results.size() > maxAutocompleteSearchResults
? maxAutocompleteSearchResults
: results.size());
}
/**
* Return the search results in a sorted map based on priority of the search result type
*
* @param resultsMap Search results map
* @return Sorted map of search results ordered on search result type priority
*/
private SortedMap<Integer, List<AutocompleteResultsModel>> getCleanedAndSortedMapResults(
ConcurrentMap<String, List<Tuple<SearchResult, String>>> resultsMap,
int maxTextLength) {
SortedMap<Integer, List<AutocompleteResultsModel>> prioritizedResultsMap =
createAutocompletePriorityMap();
// Put the results into the map of <priority,list>
for (Map.Entry<String, List<Tuple<SearchResult, String>>> entry : resultsMap.entrySet()) {
for (Tuple<SearchResult, String> tupleSearchResult : entry.getValue()) {
SearchResult searchResult = tupleSearchResult.getFirst();
List<String> resultTypes = searchResult.getType();
// If the search result doesn't have a type defined, use the undefined result type.
if (resultTypes.size() == 0) {
resultTypes = UNDEFINED_SEARCH_RESULT_TYPE;
}
for (String category : resultTypes) {
// Exclude the result if it is a result type that's in the ignore list.
if (!autocompleteIgnoreResultTypes.contains(category)) {
int priority = calculatePriorityFromCategory(category);
AutocompleteResultsModel result =
new AutocompleteResultsModel(
cleanAndTrimString(searchResult.getTitle(), maxTextLength),
cleanAndTrimString(
searchResult.getSummary(), maxTextLength),
tupleSearchResult.getSecond(),
category);
prioritizedResultsMap.get(priority).add(result);
}
}
}
}
return prioritizedResultsMap;
}
// Remove extraneous spaces, newlines, returns, tabs, etc. and limit the length. This helps improve performance
// for slower network connections and makes autocomplete UI results smaller/shorter.
private String cleanAndTrimString(String text, int maxTextLength) {
if (StringUtils.isNotBlank(text)) {
String cleaned = text.trim().replaceAll("[\\s]+", " ");
return cleaned.length() <= maxTextLength
? cleaned
: cleaned.substring(0, maxTextLength) + " ...";
}
return text;
}
private SortedMap<Integer, List<AutocompleteResultsModel>> createAutocompletePriorityMap() {
SortedMap<Integer, List<AutocompleteResultsModel>> resultsMap = new TreeMap<>();
for (Map.Entry<String, Integer> entry : autocompleteResultTypeToPriorityMap.entrySet()) {
if (!resultsMap.containsKey(entry.getValue())) {
resultsMap.put(entry.getValue(), new ArrayList<AutocompleteResultsModel>());
}
}
// Insure there is always a default entry of priority 0.
resultsMap.put(0, new ArrayList<AutocompleteResultsModel>());
return resultsMap;
}
private int calculatePriorityFromCategory(String category) {
Integer priority = autocompleteResultTypeToPriorityMap.get(category);
return priority != null ? priority : 0;
}
/**
* Get the {@link PortalSearchResults} for the specified query id from the session. If there are
* no results null is returned.
*/
private PortalSearchResults getPortalSearchResults(PortletRequest request, String queryId) {
final PortletSession session = request.getPortletSession();
@SuppressWarnings("unchecked")
final Cache<String, PortalSearchResults> searchResultsCache =
(Cache<String, PortalSearchResults>)
session.getAttribute(SEARCH_RESULTS_CACHE_NAME);
if (searchResultsCache == null) {
return null;
}
return searchResultsCache.getIfPresent(queryId);
}
/**
* @param portletSearchResults Results from a portlet
* @param results Results collating object
* @param httpServletRequest current request
* @param portletWindowId Id of the portlet window that provided the results
*/
private void addSearchResults(
SearchResults portletSearchResults,
PortalSearchResults results,
final HttpServletRequest httpServletRequest,
final IPortletWindowId portletWindowId) {
for (SearchResult result : portletSearchResults.getSearchResult()) {
final String resultUrl = this.getResultUrl(httpServletRequest, result, portletWindowId);
this.logger.debug("Created {} with from {}", resultUrl, result.getTitle());
modifySearchResultLinkTitle(result, httpServletRequest, portletWindowId);
results.addPortletSearchResults(resultUrl, result);
}
}
/**
* Since portlets don't have access to the portlet definition to create a useful search results
* link using something like the portlet definition's title, post-process the link text and for
* those portlets whose type is present in the substitution set, replace the title with the
* portlet definition's title.
*
* @param result Search results object (may be modified)
* @param httpServletRequest HttpServletRequest
* @param portletWindowId Portlet Window ID
*/
protected void modifySearchResultLinkTitle(
SearchResult result,
final HttpServletRequest httpServletRequest,
final IPortletWindowId portletWindowId) {
// If the title contains a SpEL expression, parse it with the portlet definition in the evaluation context.
if (result.getType().size() > 0 && result.getTitle().contains("${")) {
final IPortletWindow portletWindow =
this.portletWindowRegistry.getPortletWindow(
httpServletRequest, portletWindowId);
final IPortletEntity portletEntity = portletWindow.getPortletEntity();
final IPortletDefinition portletDefinition = portletEntity.getPortletDefinition();
final SpELEnvironmentRoot spelEnvironment = new SpELEnvironmentRoot(portletDefinition);
try {
result.setTitle(spELService.getValue(result.getTitle(), spelEnvironment));
} catch (SpelParseException | SpelEvaluationException e) {
result.setTitle("(Invalid portlet title) - see details in log file");
logger.error(
"Invalid Spring EL expression {} in search result portlet title",
result.getTitle(),
e);
}
}
}
/** Determine the url for the search result */
protected String getResultUrl(
HttpServletRequest httpServletRequest,
SearchResult result,
IPortletWindowId portletWindowId) {
final String externalUrl = result.getExternalUrl();
if (externalUrl != null) {
return externalUrl;
}
UrlType urlType = UrlType.RENDER;
final PortletUrl portletUrl = result.getPortletUrl();
if (portletUrl != null) {
final PortletUrlType type = portletUrl.getType();
if (type != null) {
switch (type) {
case ACTION:
{
urlType = UrlType.ACTION;
break;
}
default:
case RENDER:
{
urlType = UrlType.RENDER;
break;
}
case RESOURCE:
{
urlType = UrlType.RESOURCE;
break;
}
}
}
}
final IPortalUrlBuilder portalUrlBuilder =
this.portalUrlProvider.getPortalUrlBuilderByPortletWindow(
httpServletRequest, portletWindowId, urlType);
final IPortletUrlBuilder portletUrlBuilder =
portalUrlBuilder.getTargetedPortletUrlBuilder();
if (portletUrl != null) {
final String portletMode = portletUrl.getPortletMode();
if (portletMode != null) {
portletUrlBuilder.setPortletMode(PortletUtils.getPortletMode(portletMode));
}
final String windowState = portletUrl.getWindowState();
if (windowState != null) {
portletUrlBuilder.setWindowState(PortletUtils.getWindowState(windowState));
}
for (final PortletUrlParameter param : portletUrl.getParam()) {
final String name = param.getName();
final List<String> values = param.getValue();
portletUrlBuilder.addParameter(name, values.toArray(new String[values.size()]));
}
}
return portalUrlBuilder.getUrlString();
}
public boolean isMobile(PortletRequest request) {
String themeName = request.getProperty(IPortletRenderer.THEME_NAME_PROPERTY);
return "UniversalityMobile".equals(themeName);
}
/**
* Limited-use POJO representing the root of a SpEL environment. For Search we're only using the
* portlet object in the evaluation context.
*/
@SuppressWarnings("unused")
private class SpELEnvironmentRoot {
private final IPortletDefinition portlet;
/**
* Create a new SpEL environment root for use in a SpEL evaluation context.
*
* @param portletDefinition portlet definition
*/
private SpELEnvironmentRoot(IPortletDefinition portletDefinition) {
this.portlet = portletDefinition;
}
/** Get the portlet associated with this environment root. */
public IPortletDefinition getPortlet() {
return portlet;
}
}
}