001/*
002 *  Copyright 2019 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.instance;
017
018import java.util.Arrays;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.function.Function;
027import java.util.stream.Collectors;
028import java.util.stream.Stream;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.commons.lang3.StringUtils;
035import org.apache.commons.lang3.tuple.Pair;
036import org.apache.commons.math3.util.IntegerSequence.Incrementor;
037
038import org.ametys.cms.search.SortOrder;
039import org.ametys.cms.search.advanced.AbstractTreeNode;
040import org.ametys.cms.search.advanced.TreeMaker;
041import org.ametys.cms.search.advanced.TreeMaker.ClientSideCriterionWrapper;
042import org.ametys.core.util.JSONUtils;
043import org.ametys.plugins.repository.AmetysObjectResolver;
044import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
045import org.ametys.web.frontoffice.search.SearchService;
046import org.ametys.web.frontoffice.search.instance.model.ContextLang;
047import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion;
048import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode;
049import org.ametys.web.frontoffice.search.instance.model.Link;
050import org.ametys.web.frontoffice.search.instance.model.ResultDisplay;
051import org.ametys.web.frontoffice.search.instance.model.ResultDisplayType;
052import org.ametys.web.frontoffice.search.instance.model.RightCheckingMode;
053import org.ametys.web.frontoffice.search.instance.model.SearchContext;
054import org.ametys.web.frontoffice.search.instance.model.SiteContext;
055import org.ametys.web.frontoffice.search.instance.model.SiteContextType;
056import org.ametys.web.frontoffice.search.instance.model.SitemapContext;
057import org.ametys.web.frontoffice.search.instance.model.SitemapContextType;
058import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap;
059import org.ametys.web.frontoffice.search.metamodel.AdditionalSearchServiceParameter;
060import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator.RestrictedValues;
061import org.ametys.web.frontoffice.search.metamodel.SearchServiceFacetDefinition;
062import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator;
063import org.ametys.web.frontoffice.search.metamodel.Returnable;
064import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinition;
065import org.ametys.web.frontoffice.search.metamodel.SearchServiceCreationHelper;
066import org.ametys.web.frontoffice.search.metamodel.Searchable;
067import org.ametys.web.frontoffice.search.metamodel.SearchServiceSortDefinition;
068import org.ametys.web.repository.page.ZoneItem;
069import org.ametys.web.repository.site.Site;
070import org.ametys.web.repository.site.SiteManager;
071
072/**
073 * The component able to {@link #createSearchServiceInstance create} some {@link SearchServiceInstance}s.
074 */
075public class SearchServiceInstanceFactory implements Component, Serviceable
076{
077    /** Avalon Role */
078    public static final String ROLE = SearchServiceInstanceFactory.class.getName();
079    
080    private SearchServiceCreationHelper _serviceCreationHelper;
081    private AmetysObjectResolver _resolver;
082    private JSONUtils _json;
083    private SiteManager _siteManager;
084    private TreeMaker _treeMaker;
085    
086    @Override
087    public void service(ServiceManager manager) throws ServiceException
088    {
089        _serviceCreationHelper = (SearchServiceCreationHelper) manager.lookup(SearchServiceCreationHelper.ROLE);
090        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
091        _json = (JSONUtils) manager.lookup(JSONUtils.ROLE);
092        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
093        _treeMaker = (TreeMaker) manager.lookup(TreeMaker.ROLE);
094    }
095    
096    /**
097     * Creates a new {@link SearchServiceInstance}
098     * @param zoneItemId the id of the {@link ZoneItem}
099     * @return the created {@link SearchServiceInstance} which is placed at the given {@link ZoneItem}
100     */
101    public SearchServiceInstance createSearchServiceInstance(String zoneItemId)
102    {
103        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
104        ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters();
105        
106        List<Returnable> returnables = _serviceCreationHelper.getReturnables(Arrays.asList(serviceParameters.getValue(SearchService.PARAM_NAME_RETURNABLES)));
107        Collection<AdditionalSearchServiceParameter> additionalParameters = _serviceCreationHelper.getAdditionalParameters();
108        AdditionalParameterValueMap additionalParameterValues = _serviceCreationHelper.getAdditionalParameterValues(additionalParameters, serviceParameters);
109        
110        String[] contextIds = serviceParameters.getValue(SearchService.PARAM_NAME_CONTEXTS, false, new String[0]);
111        Collection<SearchContext> contexts = Stream.of(contextIds)
112                .map(_json::convertJsonToMap)
113                .map(this::_createSearchContext)
114                .collect(Collectors.toList());
115        
116        Collection<Searchable> searchables = _serviceCreationHelper.getSearchables(returnables);
117        Map<String, SearchServiceCriterionDefinition> criterionDefinitions = _serviceCreationHelper.getCriterionDefinitions(searchables, additionalParameterValues);
118        Incrementor incrementor = Incrementor.create()
119                .withStart(0)
120                .withMaximalCount(Integer.MAX_VALUE);
121        String treeStr = serviceParameters.getValue(SearchService.PARAM_NAME_CRITERIA);
122        Site site = zoneItem.getZone().getSitemapElement().getSite();
123        Map<String, Object> contextualParameters = _contextualParameters(contexts, site);
124        AbstractTreeNode<SearchServiceCriterion<?>> criterionTree = _createCriterionTree(_json.convertJsonToMap(treeStr), criterionDefinitions, incrementor, contextualParameters);
125        
126        Map<String, SearchServiceFacetDefinition> availableFacets = _serviceCreationHelper.getFacetDefinitions(returnables, additionalParameterValues);
127        Collection<SearchServiceFacetDefinition> facets = _createFacetDefinitions(serviceParameters, availableFacets);
128        
129        Map<String, SearchServiceSortDefinition> availableSorts = _serviceCreationHelper.getSortDefinitions(returnables, additionalParameterValues);
130        List<Pair<SearchServiceSortDefinition, SortOrder>> initialSorts = _createInitialSorts(serviceParameters, availableSorts);
131        Collection<SearchServiceSortDefinition> proposedSorts = _createProposedSortDefinitions(serviceParameters, availableSorts);
132        
133        Long resultsPerPage = serviceParameters.getValue(SearchService.PARAM_NAME_RESULTS_PER_PAGE);
134        Long maxResults  = serviceParameters.getValue(SearchService.PARAM_NAME_MAX_RESULTS);
135        
136        boolean hasUserCriteria = SearchService.hasUserCriteria(Optional.ofNullable(criterionTree));
137        boolean hasUserInput = SearchService.hasUserInput(Optional.ofNullable(criterionTree), facets, proposedSorts);
138        
139        RightCheckingMode rightCheckingMode = _rightCheckingMode(serviceParameters, hasUserInput);
140        
141        ResultDisplay resultDisplay = _createResultDisplay(serviceParameters, hasUserCriteria);
142        Link link = _createLink(serviceParameters);
143        
144        SearchServiceInstance instance = new SearchServiceInstance(
145                zoneItemId,
146                serviceParameters.getValue(SearchService.PARAM_NAME_HEADER, false, ""), 
147                returnables, 
148                searchables, 
149                additionalParameters, 
150                additionalParameterValues, 
151                contexts, 
152                criterionTree,
153                serviceParameters.getValue(SearchService.PARAM_NAME_COMPUTE_COUNTS, false, false),
154                facets,
155                initialSorts,
156                proposedSorts,
157                resultsPerPage == null ? null : resultsPerPage.intValue(),
158                maxResults == null ? null : maxResults.intValue(),
159                rightCheckingMode,
160                serviceParameters.getValue(SearchService.PARAM_NAME_XSLT),
161                resultDisplay,
162                link,
163                serviceParameters.getValue(SearchService.PARAM_NAME_RSS),
164                serviceParameters.getValue(SearchService.PARAM_NAME_SAVE_USER_PREFS, true, false)
165        );
166        return instance;
167    }
168    
169    private SearchContext _createSearchContext(Map<String, Object> config)
170    {
171        Pair<List<String>, Boolean> tags = _getTags(config.get("tags"));
172        
173        SitemapContext sitemapContext = _createSitemapContext(config.get("search-sitemap-context"));
174        return new SearchContext(
175                _createSiteContext(config.get("sites")),
176                sitemapContext,
177                _getContextLang(config.get("context-lang"), sitemapContext),
178                tags.getLeft(),
179                tags.getRight()
180        );
181    }
182    
183    @SuppressWarnings("unchecked")
184    private SiteContext _createSiteContext(Object sitesObj)
185    {
186        Map<String, Object> sitesAsMap = _json.convertJsonToMap((String) sitesObj);
187        SiteContextType siteContextType = SiteContextType.fromClientSideName((String) sitesAsMap.get("context"));
188        List<String> sites = null;
189        if (siteContextType == SiteContextType.AMONG)
190        {
191            sites = (List<String>) sitesAsMap.get("sites");
192        }
193        
194        return new SiteContext(siteContextType, sites, _siteManager);
195    }
196    
197    @SuppressWarnings("unchecked")
198    private SitemapContext _createSitemapContext(Object sitemapObj)
199    {
200        Map<String, Object> sitemapAsMap =  _json.convertJsonToMap((String) sitemapObj);
201        String sitemapContextTypeStr = (String) sitemapAsMap.get("context");
202        SitemapContextType sitemapContextType = sitemapContextTypeStr == null ? SitemapContextType.CURRENT_SITE : SitemapContextType.valueOf(sitemapContextTypeStr);
203        List<String> pageList = null;
204        if (sitemapContextType == SitemapContextType.CHILD_PAGES_OF || sitemapContextType == SitemapContextType.DIRECT_CHILD_PAGES_OF)
205        {
206            Object pagesObj = sitemapAsMap.get("page");
207            if (pagesObj instanceof String)
208            {
209                pageList = Collections.singletonList((String) pagesObj);
210            }
211            else
212            {
213                pageList = (List<String>) pagesObj;
214            }
215        }
216        
217        return new SitemapContext(sitemapContextType, pageList, _resolver);
218    }
219    
220    private ContextLang _getContextLang(Object langObj, SitemapContext sitemapContext)
221    {
222        if (sitemapContext.getType() == SitemapContextType.CURRENT_SITE)
223        {
224            return ContextLang.valueOf((String) langObj);
225        }
226        else
227        {
228            return ContextLang.ALL;
229        }
230    }
231    
232    @SuppressWarnings("unchecked")
233    private Pair<List<String>, Boolean> _getTags(Object tagsObj)
234    {
235        List<String> tagIds;
236        boolean autoposting = false;
237        if (tagsObj instanceof Map<?, ?>)
238        {
239            Map<String, Object> tagsAsMap = (Map<String, Object>) tagsObj;
240            tagIds = (List<String>) tagsAsMap.get("value");
241            autoposting = (Boolean) tagsAsMap.get("autoposting");
242        }
243        else
244        {
245            tagIds = (List<String>) tagsObj;
246        }
247        
248        return Pair.of(tagIds, autoposting);
249    }
250    
251    private AbstractTreeNode<SearchServiceCriterion<?>> _createCriterionTree(Map<String, Object> criteriaValues, Map<String, SearchServiceCriterionDefinition> criterionDefinitions, Incrementor incrementor, Map<String, Object> contextualParameters)
252    {
253        Function<ClientSideCriterionWrapper, SearchServiceCriterion<?>> leafValueMaker = crit -> _createCriterion(crit, criterionDefinitions, incrementor, contextualParameters);
254        return _treeMaker.create(criteriaValues, leafValueMaker);
255    }
256    
257    private <T> SearchServiceCriterion _createCriterion(ClientSideCriterionWrapper critWrapper, Map<String, SearchServiceCriterionDefinition> criterionDefinitions, Incrementor incrementor, Map<String, Object> contextualParameters)
258    {
259        String criterionDefId = critWrapper.getId();
260        SearchServiceCriterionDefinition<T> criterionDefinition = criterionDefinitions.get(criterionDefId);
261        Objects.requireNonNull(criterionDefinition, String.format("The criterion definition for id '%s' must be non null", criterionDefId));
262        
263        // Generate an id
264        incrementor.increment();
265        String name = criterionDefId + "$" + incrementor.getCount();
266        
267        Map<String, Object> otherProperties = critWrapper.getOtherProperties();
268        String mode = (String) otherProperties.get("mode");
269        
270        RestrictedValues<T> restrictedValues = null;
271        if ("RESTRICTED_USER_INPUT".equals(mode))
272        {
273            @SuppressWarnings("unchecked")
274            List<T> values = ((List<Object>) otherProperties.get("restrictedValues")).stream()
275                                                                                     .map(criterionDefinition::convertRestrictedValue)
276                                                                                     .toList();
277            restrictedValues = _restrictedValues(values, criterionDefinition, contextualParameters);
278        }
279        Object staticValue = "STATIC".equals(mode) ? critWrapper.getValue() : null;
280        
281        return new SearchServiceCriterion<>(
282                name,
283                criterionDefinition,
284                critWrapper.getStringOperator(),
285                SearchServiceCriterionMode.valueOf(mode),
286                restrictedValues,
287                staticValue
288        );
289    }
290    
291    private <T> RestrictedValues<T> _restrictedValues(List<T> values, SearchServiceCriterionDefinition<T> criterionDefinition, Map<String, Object> contextualParameters)
292    {
293        RestrictedEnumerator<T> enumerator = criterionDefinition.getRestrictedEnumerator(contextualParameters);
294        if (enumerator != null)
295        {
296            try
297            {
298                return enumerator.getRestrictedEntriesFor(values);
299            }
300            catch (Exception e)
301            {
302                // An error occurred while retrieving restricted values
303                throw new IllegalStateException("An unexpected error occured. Unable to compute restricted values for criterion '" + criterionDefinition.getName() + "'", e);
304            }
305        }
306        else
307        {
308            throw new IllegalStateException("An unexpected error occured. There must be restricted values at this point.");
309        }
310    }
311    
312    private Map<String, Object> _contextualParameters(Collection<SearchContext> searchContexts, Site currentSite)
313    {
314        Map<String, Object> contextualParameters = new HashMap<>();
315        
316        contextualParameters.put("searchContexts", searchContexts);
317        contextualParameters.put("siteName", currentSite.getName());
318        
319        return contextualParameters;
320    }
321    
322    private Collection<SearchServiceFacetDefinition> _createFacetDefinitions(ModelAwareDataHolder serviceParameters, Map<String, SearchServiceFacetDefinition> availableFacets)
323    {
324        if (serviceParameters.hasDefinition(SearchService.PARAM_NAME_FACETS))
325        {
326            String[] facetStrs = serviceParameters.getValue(SearchService.PARAM_NAME_FACETS, false, new String[0]);
327            return Stream.of(facetStrs)
328                    .map(availableFacets::get)
329                    .filter(Objects::nonNull)
330                    .collect(Collectors.toList());
331        }
332        return Collections.EMPTY_LIST;
333        
334    }
335    
336    private List<Pair<SearchServiceSortDefinition, SortOrder>> _createInitialSorts(ModelAwareDataHolder serviceParameters, Map<String, SearchServiceSortDefinition> availableSorts)
337    {
338        if (serviceParameters.hasDefinition(SearchService.PARAM_NAME_INITIAL_SORTS))
339        {
340            String[] initialSortIds = serviceParameters.getValue(SearchService.PARAM_NAME_INITIAL_SORTS, false, new String[0]);
341            return Stream.of(initialSortIds)
342                    .map(_json::convertJsonToMap)
343                    .map(json -> _initialSort(json, availableSorts))
344                    .collect(Collectors.toList());
345        }
346        return Collections.EMPTY_LIST;
347    }
348    
349    private Pair<SearchServiceSortDefinition, SortOrder> _initialSort(Map<String, Object> json, Map<String, SearchServiceSortDefinition> availableSorts)
350    {
351        String sortDefId = (String) json.get("name");
352        SortOrder direction = SortOrder.valueOf((String) json.get("sort"));
353        SearchServiceSortDefinition sortDef = availableSorts.get(sortDefId);
354        Objects.requireNonNull(sortDef);
355        return Pair.of(sortDef, direction);
356    }
357    
358    private Collection<SearchServiceSortDefinition> _createProposedSortDefinitions(ModelAwareDataHolder serviceParameters, Map<String, SearchServiceSortDefinition> availableSorts)
359    {
360        if (serviceParameters.hasDefinition(SearchService.PARAM_NAME_PROPOSED_SORTS))
361        {
362            String[] proposedSortStrs = serviceParameters.getValue(SearchService.PARAM_NAME_PROPOSED_SORTS, false, new String[0]);
363            return Stream.of(proposedSortStrs)
364                    .map(availableSorts::get)
365                    .filter(Objects::nonNull)
366                    .collect(Collectors.toList());
367        }
368        return Collections.EMPTY_LIST;
369    }
370    
371    private RightCheckingMode _rightCheckingMode(ModelAwareDataHolder serviceParameters, boolean hasUserInput)
372    {
373        String rightCheckingModeStr = serviceParameters.getValue(SearchService.PARAM_NAME_RIGHT_CHECKING_MODE);
374        RightCheckingMode rightCheckingMode = RightCheckingMode.valueOf(rightCheckingModeStr.toUpperCase());
375        if (hasUserInput && rightCheckingMode == RightCheckingMode.FAST)
376        {
377            // It cannot be cached with FAST => force to EXACT
378            return RightCheckingMode.EXACT;
379        }
380        else
381        {
382            return rightCheckingMode;
383        }
384    }
385    
386    private ResultDisplay _createResultDisplay(ModelAwareDataHolder serviceParameters, boolean hasUserCriteria)
387    {
388        ResultDisplayType type;
389        if (hasUserCriteria)
390        {
391            type = ResultDisplayType.valueOf(serviceParameters.getValue(SearchService.PARAM_NAME_RESULT_PLACE));
392        }
393        else
394        {
395            type = ResultDisplayType.ABOVE_CRITERIA;
396        }
397        
398        String pageId = null;
399        if (type == ResultDisplayType.ON_PAGE)
400        {
401            pageId = serviceParameters.getValue(SearchService.PARAM_NAME_RESULT_PAGE);
402        }
403        
404        Boolean launchSearchAtStartup = null;
405        if (!hasUserCriteria)
406        {
407            // Force to true
408            launchSearchAtStartup = true;
409        }
410        else if (type != ResultDisplayType.ON_PAGE)
411        {
412            launchSearchAtStartup = serviceParameters.getValue(SearchService.PARAM_NAME_LAUNCH_SEARCH_AT_STARTUP);
413        }
414        
415        String serviceGroupId = "";
416        if (hasUserCriteria && serviceParameters.hasValue(SearchService.PARAM_NAME_SERVICE_GROUP_ID))
417        {
418            serviceGroupId = serviceParameters.getValue(SearchService.PARAM_NAME_SERVICE_GROUP_ID);
419        }
420        
421        return new ResultDisplay(type, pageId, launchSearchAtStartup, serviceGroupId, _resolver);
422    }
423    
424    private Link _createLink(ModelAwareDataHolder serviceParameters)
425    {
426        String targetPageId = serviceParameters.getValue(SearchService.PARAM_NAME_LINK_PAGE);
427        String targetPage = StringUtils.isNotEmpty(targetPageId) ? targetPageId : null;
428        String title = serviceParameters.getValue(SearchService.PARAM_NAME_LINK_TITLE, false, "");
429        return new Link(targetPage, title, _resolver);
430    }
431}
432