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