001/*
002 *  Copyright 2018 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.web.frontoffice.search;
017
018import java.util.Arrays;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.Comparator;
022import java.util.HashMap;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Optional;
027import java.util.function.Function;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.configuration.DefaultConfiguration;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.cocoon.components.ContextHelper;
036import org.apache.cocoon.environment.Request;
037import org.apache.commons.lang3.StringUtils;
038
039import org.ametys.cms.search.advanced.AbstractTreeNode;
040import org.ametys.cms.search.advanced.TreeLeaf;
041import org.ametys.core.util.LambdaUtils;
042import org.ametys.runtime.i18n.I18nizableText;
043import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
044import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager;
045import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterion;
046import org.ametys.web.frontoffice.search.instance.model.ResultDisplay;
047import org.ametys.web.frontoffice.search.instance.model.ResultDisplayType;
048import org.ametys.web.frontoffice.search.instance.model.RightCheckingMode;
049import org.ametys.web.frontoffice.search.metamodel.AdditionalSearchServiceParameter;
050import org.ametys.web.frontoffice.search.metamodel.FacetDefinition;
051import org.ametys.web.frontoffice.search.metamodel.SearchServiceCreationHelper;
052import org.ametys.web.frontoffice.search.metamodel.SortDefinition;
053import org.ametys.web.frontoffice.search.requesttime.SearchServiceDebugModeHelper;
054import org.ametys.web.renderingcontext.RenderingContextHandler;
055import org.ametys.web.repository.page.Page;
056import org.ametys.web.repository.page.ZoneItem;
057import org.ametys.web.service.ServiceParameter;
058import org.ametys.web.service.StaticService;
059
060/**
061 * Front search service.
062 */
063public class SearchService extends StaticService
064{
065    /** Avalon Role */
066    public static final String ROLE = "org.ametys.web.service.SearchService";
067    
068    /** The parameter name for header */
069    public static final String PARAM_NAME_HEADER = "header";
070    /** The parameter name for returnables */
071    public static final String PARAM_NAME_RETURNABLES = "returnables";
072    /** The parameter name for contexts */
073    public static final String PARAM_NAME_CONTEXTS = "contexts";
074    /** The parameter name for criteria */
075    public static final String PARAM_NAME_CRITERIA = "criteria";
076    /** The parameter name for computing counts */
077    public static final String PARAM_NAME_COMPUTE_COUNTS = "computeCounts";
078    /** The parameter name for facets */
079    public static final String PARAM_NAME_FACETS = "facets";
080    /** The parameter name for initial sorts */
081    public static final String PARAM_NAME_INITIAL_SORTS = "initialSorts";
082    /** The parameter name for proposed sorts */
083    public static final String PARAM_NAME_PROPOSED_SORTS = "proposedSorts";
084    /** The parameter name for number of results per page */
085    public static final String PARAM_NAME_RESULTS_PER_PAGE = "resultsPerPage";
086    /** The parameter name for maximum number of results */
087    public static final String PARAM_NAME_MAX_RESULTS = "maxResults";
088    /** The parameter name for right checking mode */
089    public static final String PARAM_NAME_RIGHT_CHECKING_MODE = "rightCheckingMode";
090    /** The parameter name for XSLT */
091    public static final String PARAM_NAME_XSLT = "xslt";
092    /** The parameter name for result place */
093    public static final String PARAM_NAME_RESULT_PLACE = "resultPlace";
094    /** The parameter name for result page */
095    public static final String PARAM_NAME_RESULT_PAGE = "resultPage";
096    /** The parameter name for launching search at startup */
097    public static final String PARAM_NAME_LAUNCH_SEARCH_AT_STARTUP = "launchSearchAtStartup";
098    /** The parameter name for service group id */
099    public static final String PARAM_NAME_SERVICE_GROUP_ID = "serviceGroupId";
100    /** The parameter name for link page */
101    public static final String PARAM_NAME_LINK_PAGE = "link";
102    /** The parameter name for link title */
103    public static final String PARAM_NAME_LINK_TITLE = "linkTitle";
104    /** The parameter name for handling RSS */
105    public static final String PARAM_NAME_RSS = "rss";
106    /** The parameter name for saving user preferences */
107    public static final String PARAM_NAME_SAVE_USER_PREFS = "saveUserPrefs";
108    
109    /** The helper component for defining this service */
110    protected SearchServiceCreationHelper _creationHelper;
111    /** The handler of rendering context */
112    protected RenderingContextHandler _renderingContextHandler;
113    /** The manager for {@link SearchServiceInstance} */
114    protected SearchServiceInstanceManager _searchServiceInstanceManager;
115
116    @Override
117    public void service(ServiceManager smanager) throws ServiceException
118    {
119        super.service(smanager);
120        _creationHelper = (SearchServiceCreationHelper) smanager.lookup(SearchServiceCreationHelper.ROLE);
121        _renderingContextHandler = (RenderingContextHandler) smanager.lookup(RenderingContextHandler.ROLE);
122        _searchServiceInstanceManager = (SearchServiceInstanceManager) smanager.lookup(SearchServiceInstanceManager.ROLE);
123    }
124    
125    /**
126     * Returns <code>true</code> if the given instance of search service has some user input
127     * @param criterionTree The tree of criteria of the search service instance
128     * @param facets The facets of the search service instance
129     * @param proposedSorts The proposed sorts of the search service instance
130     * @return <code>true</code> if the given instance of search service has some user input
131     */
132    public static boolean hasUserInput(Optional<AbstractTreeNode<FOSearchCriterion>> criterionTree, Collection<FacetDefinition> facets, Collection<SortDefinition> proposedSorts)
133    {
134        boolean hasUserCriteria = hasUserCriteria(criterionTree);
135        
136        boolean hasUserFacet = !facets.isEmpty();
137        
138        boolean hasUserSort = !proposedSorts.isEmpty();
139        
140        return hasUserCriteria || hasUserFacet || hasUserSort;
141    }
142    
143    /**
144     * Returns <code>true</code> if the given instance of search service has at least one user criterion
145     * @param criterionTree The tree of criteria of the search service instance
146     * @return <code>true</code> if the given instance of search service has at least one user criterion
147     */
148    public static boolean hasUserCriteria(Optional<AbstractTreeNode<FOSearchCriterion>> criterionTree)
149    {
150        return criterionTree
151                .map(AbstractTreeNode::getFlatLeaves)
152                .orElse(Collections.emptySet())
153                .stream()
154                .map(TreeLeaf::getValue)
155                .anyMatch(crit -> !crit.getMode().isStatic());
156    }
157    
158    private static boolean _hasUserInput(SearchServiceInstance serviceInstance)
159    {
160        return hasUserInput(serviceInstance.getCriterionTree(), serviceInstance.getFacets(), serviceInstance.getProposedSorts());
161    }
162    
163    @Override
164    public boolean isCacheable(Page currentPage, ZoneItem zoneItem)
165    {
166        boolean isDebug = _isDebug(ContextHelper.getRequest(_context), _renderingContextHandler);
167        
168        if (isDebug)
169        {
170            return false;
171        }
172        
173        SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItem.getId());
174        ResultDisplay resultDisplay = serviceInstance.getResultDisplay();
175        if (resultDisplay.getType() == ResultDisplayType.ON_PAGE)
176        {
177            String resultPageId = resultDisplay
178                    .resultPage()
179                    .map(Page::getId)
180                    .orElseThrow(() -> new IllegalArgumentException("Error. resultPage() cannot return empty at this time."));
181            // Cacheable if results are on ANOTHER PAGE
182            return !resultPageId.equals(currentPage.getId());
183        }
184        
185        // Cacheable if no user input and right checking mode is not 'EXACT'
186        return serviceInstance.getRightCheckingMode() != RightCheckingMode.EXACT && !_hasUserInput(serviceInstance);
187    }
188    
189    private boolean _isDebug(Request request, RenderingContextHandler renderingContextHandler)
190    {
191        return SearchServiceDebugModeHelper.debugMode(request, renderingContextHandler) != null;
192    }
193    
194    @Override
195    protected void configureParameters(Configuration parametersConfiguration) throws ConfigurationException
196    {
197        _ensureCreationHelperIsReady();
198        
199        DefaultConfiguration confWithAdditionalParameters = new DefaultConfiguration(parametersConfiguration);
200        
201        Configuration[] groups = confWithAdditionalParameters.getChildren("group");
202        Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameterConfs = new LinkedHashMap<>();
203        
204        // first group 'General'
205        DefaultConfiguration generalGroupConf = (DefaultConfiguration) groups[0];
206        parsedAdditionalParameterConfs.putAll(_parseAndInjectGeneralAdditionalParameters(generalGroupConf));
207        
208        // last group 'Display' > first fieldset 'Display'
209        DefaultConfiguration displayGroupConf = (DefaultConfiguration) groups[groups.length - 1];
210        Configuration[] fieldsets = displayGroupConf.getChildren("fieldset");
211        DefaultConfiguration displayFieldsetConf = (DefaultConfiguration) fieldsets[0];
212        parsedAdditionalParameterConfs.putAll(_parseAndInjectDisplayAdditionalParameters(displayFieldsetConf));
213        
214        // configure of superclass
215        super.configureParameters(confWithAdditionalParameters);
216
217        // parameters are ready => save the additional ones
218        Collection<AdditionalSearchServiceParameter> additionalParameters = _buildAdditionalSearchServiceParameters(parsedAdditionalParameterConfs);
219        _creationHelper.setAdditionalParameters(additionalParameters);
220        
221        // Default value of returnables
222        String defaultValue = StringUtils.join(_creationHelper.selectedReturnables(), ',');
223        @SuppressWarnings("unchecked")
224        ServiceParameter<String> paramReturnables = (ServiceParameter<String>) _modelItems.get(PARAM_NAME_RETURNABLES);
225        paramReturnables.setDefaultValue(defaultValue);
226        
227        // Criteria, facets, initial sorts and proposed sorts need widget parameters
228        List<String> fieldsReloadingWidget = _fieldsReloadingWidget(additionalParameters);
229        _setWidgetParameters(PARAM_NAME_CRITERIA, fieldsReloadingWidget);
230        _setWidgetParameters(PARAM_NAME_FACETS, fieldsReloadingWidget);
231        _setWidgetParameters(PARAM_NAME_INITIAL_SORTS, fieldsReloadingWidget);
232        _setWidgetParameters(PARAM_NAME_PROPOSED_SORTS, fieldsReloadingWidget);
233    }
234    
235    private void _ensureCreationHelperIsReady() throws ConfigurationException
236    {
237        if (!_creationHelper.isReady())
238        {
239            try
240            {
241                _creationHelper.initialize();
242            }
243            catch (Exception e)
244            {
245                throw new ConfigurationException("An error occured when initializing " + SearchServiceCreationHelper.ROLE, e);
246            }
247        }
248    }
249    
250    static final class ParsedAdditionalParameterConf
251    {
252        String _name;
253        boolean _reload;
254        
255        private ParsedAdditionalParameterConf(String name, boolean reload)
256        {
257            _name = name;
258            _reload = reload;
259        }
260        
261        static ParsedAdditionalParameterConf _fromConf(Configuration c)
262        {
263            String name = c.getAttribute("name", null);
264            boolean reload = c.getAttributeAsBoolean("reloadCriteriaOnChange", false);
265            return new ParsedAdditionalParameterConf(name, reload);
266        }
267        
268        String name()
269        {
270            return _name;
271        }
272    }
273    
274    /**
275     * Retrieve the configurations of additional parameters for 'General' group, inject them into the group configuration and return their parsed representation.
276     * @param containerConf The container configuration for injecting.
277     * @return the {@link ParsedAdditionalParameterConf}s
278     */
279    protected Map<String, ParsedAdditionalParameterConf> _parseAndInjectGeneralAdditionalParameters(DefaultConfiguration containerConf)
280    {
281        Collection<Configuration> additionalParameterConfigurations = _creationHelper.getAdditionalParameterConfigurationsForGeneral();
282        return _parseAndInjectAdditionalParameters(additionalParameterConfigurations, containerConf);
283    }
284    
285    /**
286     * Retrieve the configurations of additional parameters for 'Display' group, inject them into the group configuration and return their parsed representation.
287     * @param containerConf The container configuration for injecting.
288     * @return the {@link ParsedAdditionalParameterConf}s
289     */
290    protected Map<String, ParsedAdditionalParameterConf> _parseAndInjectDisplayAdditionalParameters(DefaultConfiguration containerConf)
291    {
292        // The additional parameters have to be inserted at the beginning of the given container
293
294        // Remove the current children
295        Configuration[] currentContainerChildren = containerConf.getChildren();
296        Arrays.stream(currentContainerChildren)
297            .forEach(containerConf::removeChild);
298        
299        // Inject the additional parameters
300        Collection<Configuration> additionalParameterConfigurations = _creationHelper.getAdditionalParameterConfigurationsForDisplay();
301        Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameters = _parseAndInjectAdditionalParameters(additionalParameterConfigurations, containerConf);
302        
303        // Add the removed parameters
304        Arrays.stream(currentContainerChildren)
305            .forEach(containerConf::addChild);
306        
307        return parsedAdditionalParameters;
308    }
309    
310    private Map<String, ParsedAdditionalParameterConf> _parseAndInjectAdditionalParameters(Collection<Configuration> additionalParameterConfigurations, DefaultConfiguration containerConf)
311    {
312        List<Configuration> orderedAdditionalParameterConfigurations = additionalParameterConfigurations.stream()
313                .sorted(Comparator.comparingLong(c -> c.getAttributeAsLong("order", Long.MAX_VALUE)))
314                .collect(Collectors.toList());
315        
316        // add additional confs to the 'Display' group conf
317        orderedAdditionalParameterConfigurations.forEach(containerConf::addChild);
318        
319        return orderedAdditionalParameterConfigurations
320                .stream()
321                .map(ParsedAdditionalParameterConf::_fromConf)
322                .collect(LambdaUtils.Collectors.toLinkedHashMap(
323                    ParsedAdditionalParameterConf::name, 
324                    Function.identity()));
325    }
326    
327    /**
328     * Build the {@link AdditionalSearchServiceParameter}s from intermediate {@link ParsedAdditionalParameterConf} objects.
329     * @param parsedAdditionalParameterConfs The parsed configurations for each additional parameter
330     * @return the {@link AdditionalSearchServiceParameter}s
331     */
332    protected Collection<AdditionalSearchServiceParameter> _buildAdditionalSearchServiceParameters(Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameterConfs)
333    {
334        List<String> additionalParameterNames = parsedAdditionalParameterConfs.keySet()
335                .stream()
336                .collect(Collectors.toList());
337        
338        return _modelItems.entrySet()
339                .stream()
340                .filter(p -> additionalParameterNames.contains(p.getKey()))
341                .map(Map.Entry::getValue)
342                .filter(ServiceParameter.class::isInstance)
343                .map(p -> 
344                {
345                    @SuppressWarnings("unchecked")
346                    ServiceParameter<Object> typedParam = (ServiceParameter<Object>) p;
347                    String paramName = typedParam.getName();
348                    ParsedAdditionalParameterConf parsedConf = parsedAdditionalParameterConfs.get(paramName);
349                    return new AdditionalSearchServiceParameter<>(typedParam, parsedConf._reload);
350                })
351                .collect(Collectors.toList());
352    }
353    
354    /**
355     * Set the widget parameters of the given parameter, by adding the list of the fields reloading the widget
356     * @param parameterName The parameter name
357     * @param fieldsReloadingCriteriaWidget the names of the fields which will launch a reload of the widget when their value change
358     */
359    protected void _setWidgetParameters(String parameterName, List<String> fieldsReloadingCriteriaWidget)
360    {
361        @SuppressWarnings("unchecked")
362        ServiceParameter<String> parameter = (ServiceParameter<String>) _modelItems.get(parameterName);
363        Map<String, I18nizableText> params = new HashMap<>();
364        Optional.ofNullable(parameter.getWidgetParameters())
365                .ifPresent(params::putAll);
366        params.put("fieldsReloadingWidget", new I18nizableText(StringUtils.join(fieldsReloadingCriteriaWidget, ",")));
367        parameter.setWidgetParameters(params);
368    }
369    
370    /**
371     * Gets the names of the fields which will launch a reload of this widget when their value change
372     * @param additionalParameters The additional parameters
373     * @return the names of the fields which will launch a reload of this widget when their value change
374     */
375    protected List<String> _fieldsReloadingWidget(Collection<AdditionalSearchServiceParameter> additionalParameters)
376    {
377        return additionalParameters
378                .stream()
379                .filter(AdditionalSearchServiceParameter::reloadCriteriaOnChange)
380                .map(AdditionalSearchServiceParameter::getParameter)
381                .map(ServiceParameter::getName)
382                .collect(Collectors.toList());
383    }
384}