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        SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItem.getId());
168        ResultDisplay resultDisplay = serviceInstance.getResultDisplay();
169        if (!isDebug && resultDisplay.getType() == ResultDisplayType.ON_PAGE)
170        {
171            String resultPageId = resultDisplay
172                    .resultPage()
173                    .map(Page::getId)
174                    .orElseThrow(() -> new IllegalArgumentException("Error. resultPage() cannot return empty at this time."));
175            // Cacheable if results are on ANOTHER PAGE
176            return !resultPageId.equals(currentPage.getId());
177        }
178        
179        if (!isDebug)
180        {
181            // Cacheable if no user input and right checking mode is on 'FAST'
182            return !_hasUserInput(serviceInstance) && serviceInstance.getRightCheckingMode() == RightCheckingMode.FAST;
183        }
184        
185        return false;
186    }
187    
188    private boolean _isDebug(Request request, RenderingContextHandler renderingContextHandler)
189    {
190        return SearchServiceDebugModeHelper.debugMode(request, renderingContextHandler) != null;
191    }
192    
193    @Override
194    protected void configureParameters(Configuration parametersConfiguration) throws ConfigurationException
195    {
196        _ensureCreationHelperIsReady();
197        
198        DefaultConfiguration confWithAdditionalParameters = new DefaultConfiguration(parametersConfiguration);
199        
200        Configuration[] groups = confWithAdditionalParameters.getChildren("group");
201        Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameterConfs = new LinkedHashMap<>();
202        
203        // first group 'General'
204        DefaultConfiguration generalGroupConf = (DefaultConfiguration) groups[0];
205        parsedAdditionalParameterConfs.putAll(_parseAndInjectGeneralAdditionalParameters(generalGroupConf));
206        
207        // last group 'Display' > first fieldset 'Display'
208        DefaultConfiguration displayGroupConf = (DefaultConfiguration) groups[groups.length - 1];
209        Configuration[] fieldsets = displayGroupConf.getChildren("fieldset");
210        DefaultConfiguration displayFieldsetConf = (DefaultConfiguration) fieldsets[0];
211        parsedAdditionalParameterConfs.putAll(_parseAndInjectDisplayAdditionalParameters(displayFieldsetConf));
212        
213        // configure of superclass
214        super.configureParameters(confWithAdditionalParameters);
215
216        // parameters are ready => save the additional ones
217        Collection<AdditionalSearchServiceParameter> additionalParameters = _buildAdditionalSearchServiceParameters(parsedAdditionalParameterConfs);
218        _creationHelper.setAdditionalParameters(additionalParameters);
219        
220        // Default value of returnables
221        String defaultValue = StringUtils.join(_creationHelper.selectedReturnables(), ',');
222        @SuppressWarnings("unchecked")
223        ServiceParameter<String> paramReturnables = (ServiceParameter<String>) _modelItems.get(PARAM_NAME_RETURNABLES);
224        paramReturnables.setDefaultValue(defaultValue);
225        
226        // Criteria, facets, initial sorts and proposed sorts need widget parameters
227        List<String> fieldsReloadingWidget = _fieldsReloadingWidget(additionalParameters);
228        _setWidgetParameters(PARAM_NAME_CRITERIA, fieldsReloadingWidget);
229        _setWidgetParameters(PARAM_NAME_FACETS, fieldsReloadingWidget);
230        _setWidgetParameters(PARAM_NAME_INITIAL_SORTS, fieldsReloadingWidget);
231        _setWidgetParameters(PARAM_NAME_PROPOSED_SORTS, fieldsReloadingWidget);
232    }
233    
234    private void _ensureCreationHelperIsReady() throws ConfigurationException
235    {
236        if (!_creationHelper.isReady())
237        {
238            try
239            {
240                _creationHelper.initialize();
241            }
242            catch (Exception e)
243            {
244                throw new ConfigurationException("An error occured when initializing " + SearchServiceCreationHelper.ROLE, e);
245            }
246        }
247    }
248    
249    static final class ParsedAdditionalParameterConf
250    {
251        String _name;
252        boolean _reload;
253        
254        private ParsedAdditionalParameterConf(String name, boolean reload)
255        {
256            _name = name;
257            _reload = reload;
258        }
259        
260        static ParsedAdditionalParameterConf _fromConf(Configuration c)
261        {
262            String name = c.getAttribute("name", null);
263            boolean reload = c.getAttributeAsBoolean("reloadCriteriaOnChange", false);
264            return new ParsedAdditionalParameterConf(name, reload);
265        }
266        
267        String name()
268        {
269            return _name;
270        }
271    }
272    
273    /**
274     * Retrieve the configurations of additional parameters for 'General' group, inject them into the group configuration and return their parsed representation.
275     * @param containerConf The container configuration for injecting.
276     * @return the {@link ParsedAdditionalParameterConf}s
277     */
278    protected Map<String, ParsedAdditionalParameterConf> _parseAndInjectGeneralAdditionalParameters(DefaultConfiguration containerConf)
279    {
280        Collection<Configuration> additionalParameterConfigurations = _creationHelper.getAdditionalParameterConfigurationsForGeneral();
281        return _parseAndInjectAdditionalParameters(additionalParameterConfigurations, containerConf);
282    }
283    
284    /**
285     * Retrieve the configurations of additional parameters for 'Display' group, inject them into the group configuration and return their parsed representation.
286     * @param containerConf The container configuration for injecting.
287     * @return the {@link ParsedAdditionalParameterConf}s
288     */
289    protected Map<String, ParsedAdditionalParameterConf> _parseAndInjectDisplayAdditionalParameters(DefaultConfiguration containerConf)
290    {
291        // The additional parameters have to be inserted at the beginning of the given container
292
293        // Remove the current children
294        Configuration[] currentContainerChildren = containerConf.getChildren();
295        Arrays.stream(currentContainerChildren)
296            .forEach(containerConf::removeChild);
297        
298        // Inject the additional parameters
299        Collection<Configuration> additionalParameterConfigurations = _creationHelper.getAdditionalParameterConfigurationsForDisplay();
300        Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameters = _parseAndInjectAdditionalParameters(additionalParameterConfigurations, containerConf);
301        
302        // Add the removed parameters
303        Arrays.stream(currentContainerChildren)
304            .forEach(containerConf::addChild);
305        
306        return parsedAdditionalParameters;
307    }
308    
309    private Map<String, ParsedAdditionalParameterConf> _parseAndInjectAdditionalParameters(Collection<Configuration> additionalParameterConfigurations, DefaultConfiguration containerConf)
310    {
311        List<Configuration> orderedAdditionalParameterConfigurations = additionalParameterConfigurations.stream()
312                .sorted(Comparator.comparingLong(c -> c.getAttributeAsLong("order", Long.MAX_VALUE)))
313                .collect(Collectors.toList());
314        
315        // add additional confs to the 'Display' group conf
316        orderedAdditionalParameterConfigurations.forEach(containerConf::addChild);
317        
318        return orderedAdditionalParameterConfigurations
319                .stream()
320                .map(ParsedAdditionalParameterConf::_fromConf)
321                .collect(LambdaUtils.Collectors.toLinkedHashMap(
322                    ParsedAdditionalParameterConf::name, 
323                    Function.identity()));
324    }
325    
326    /**
327     * Build the {@link AdditionalSearchServiceParameter}s from intermediate {@link ParsedAdditionalParameterConf} objects.
328     * @param parsedAdditionalParameterConfs The parsed configurations for each additional parameter
329     * @return the {@link AdditionalSearchServiceParameter}s
330     */
331    protected Collection<AdditionalSearchServiceParameter> _buildAdditionalSearchServiceParameters(Map<String, ParsedAdditionalParameterConf> parsedAdditionalParameterConfs)
332    {
333        List<String> additionalParameterNames = parsedAdditionalParameterConfs.keySet()
334                .stream()
335                .collect(Collectors.toList());
336        
337        return _modelItems.entrySet()
338                .stream()
339                .filter(p -> additionalParameterNames.contains(p.getKey()))
340                .map(Map.Entry::getValue)
341                .filter(ServiceParameter.class::isInstance)
342                .map(p -> 
343                {
344                    @SuppressWarnings("unchecked")
345                    ServiceParameter<Object> typedParam = (ServiceParameter<Object>) p;
346                    String paramName = typedParam.getName();
347                    ParsedAdditionalParameterConf parsedConf = parsedAdditionalParameterConfs.get(paramName);
348                    return new AdditionalSearchServiceParameter<>(typedParam, parsedConf._reload);
349                })
350                .collect(Collectors.toList());
351    }
352    
353    /**
354     * Set the widget parameters of the given parameter, by adding the list of the fields reloading the widget
355     * @param parameterName The parameter name
356     * @param fieldsReloadingCriteriaWidget the names of the fields which will launch a reload of the widget when their value change
357     */
358    protected void _setWidgetParameters(String parameterName, List<String> fieldsReloadingCriteriaWidget)
359    {
360        @SuppressWarnings("unchecked")
361        ServiceParameter<String> parameter = (ServiceParameter<String>) _modelItems.get(parameterName);
362        Map<String, I18nizableText> params = new HashMap<>();
363        Optional.ofNullable(parameter.getWidgetParameters())
364                .ifPresent(params::putAll);
365        params.put("fieldsReloadingWidget", new I18nizableText(StringUtils.join(fieldsReloadingCriteriaWidget, ",")));
366        parameter.setWidgetParameters(params);
367    }
368    
369    /**
370     * Gets the names of the fields which will launch a reload of this widget when their value change
371     * @param additionalParameters The additional parameters
372     * @return the names of the fields which will launch a reload of this widget when their value change
373     */
374    protected List<String> _fieldsReloadingWidget(Collection<AdditionalSearchServiceParameter> additionalParameters)
375    {
376        return additionalParameters
377                .stream()
378                .filter(AdditionalSearchServiceParameter::reloadCriteriaOnChange)
379                .map(AdditionalSearchServiceParameter::getParameter)
380                .map(ServiceParameter::getName)
381                .collect(Collectors.toList());
382    }
383}