001/*
002 *  Copyright 2020 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.plugins.mobileapp;
017
018import java.time.Instant;
019import java.time.LocalDate;
020import java.time.ZoneId;
021import java.time.ZonedDateTime;
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.Date;
025import java.util.HashMap;
026import java.util.Iterator;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.context.Context;
034import org.apache.avalon.framework.context.ContextException;
035import org.apache.avalon.framework.context.Contextualizable;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.avalon.framework.service.Serviceable;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.cms.contenttype.ContentTypesHelper;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.search.Sort;
044import org.ametys.cms.search.Sort.Order;
045import org.ametys.cms.search.content.ContentSearcherFactory;
046import org.ametys.cms.search.content.ContentSearcherFactory.SearchModelContentSearcher;
047import org.ametys.cms.search.ui.model.SearchUIModel;
048import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint;
049import org.ametys.cms.transformation.xslt.ResolveURIComponent;
050import org.ametys.core.util.DateUtils;
051import org.ametys.plugins.queriesdirectory.Query;
052import org.ametys.plugins.queriesdirectory.QueryContainer;
053import org.ametys.plugins.repository.AmetysObject;
054import org.ametys.plugins.repository.AmetysObjectIterable;
055import org.ametys.plugins.repository.AmetysObjectResolver;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.model.ModelItem;
058import org.ametys.runtime.model.exception.UndefinedItemPathException;
059import org.ametys.runtime.model.type.ModelItemTypeConstants;
060import org.ametys.runtime.plugin.component.AbstractLogEnabled;
061import org.ametys.web.repository.content.WebContent;
062import org.ametys.web.repository.page.Page;
063import org.ametys.web.repository.site.SiteManager;
064
065import com.google.common.collect.ImmutableList;
066
067/**
068 * Manager to list and execute queries
069 */
070public class QueriesHelper extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
071{
072    /** Avalon Role */
073    public static final String ROLE = QueriesHelper.class.getName();
074    
075    /** global configuration about queries */
076    protected static final String __QUERY_CONTAINER_CONF_ID = "plugin.mobileapp.query.container";
077    
078    /** global configuration about queries limit */
079    protected static final String __QUERY_LIMIT_CONF_ID = "plugin.mobileapp.query.limit";
080    
081    /** The service manager */
082    protected ServiceManager _manager;
083    
084    /** The context */
085    protected Context _context;
086    
087    /** The Ametys object resolver */
088    private AmetysObjectResolver _resolver;
089    
090    /** Queries Helper */
091    private org.ametys.plugins.queriesdirectory.QueryHelper _queriesHelper;
092    
093    /** SearchUI Model Extension Point */
094    private SearchUIModelExtensionPoint _searchUiEP;
095
096    /** The helper to handler content types */
097    private ContentTypesHelper _contentTypesHelper;
098    
099    /** Content Searcher Factory */
100    private ContentSearcherFactory _contentSearcherFactory;
101    
102    private SiteManager _siteManager;
103
104    public void service(ServiceManager manager) throws ServiceException
105    {
106        _manager = manager;
107    }
108    
109    /**
110     * Getter for AmetysObjectResolver to avoid problems at startup
111     * @return AmetysObjectResolver
112     */
113    protected AmetysObjectResolver getResolver()
114    {
115        if (_resolver == null)
116        {
117            try
118            {
119                _resolver = (AmetysObjectResolver) _manager.lookup(AmetysObjectResolver.ROLE);
120            }
121            catch (ServiceException e)
122            {
123                getLogger().error("Error while retrieving AmetysObjectResolver", e);
124            }
125        }
126        return _resolver;
127    }
128
129    /**
130     * Getter for QueriesManager to avoid problems at startup
131     * @return QueriesManager
132     */
133    protected org.ametys.plugins.queriesdirectory.QueryHelper getQueriesManager()
134    {
135        if (_queriesHelper == null)
136        {
137            try
138            {
139                _queriesHelper = (org.ametys.plugins.queriesdirectory.QueryHelper) _manager.lookup(org.ametys.plugins.queriesdirectory.QueryHelper.ROLE);
140            }
141            catch (ServiceException e)
142            {
143                getLogger().error("Error while retrieving QueriesManager", e);
144            }
145        }
146        return _queriesHelper;
147    }
148
149    /**
150     * Getter for SearchUIModelExtensionPoint to avoid problems at startup
151     * @return SearchUIModelExtensionPoint
152     */
153    protected SearchUIModelExtensionPoint getSearchUIModelExtensionPoint()
154    {
155        if (_searchUiEP == null)
156        {
157            try
158            {
159                _searchUiEP = (SearchUIModelExtensionPoint) _manager.lookup(SearchUIModelExtensionPoint.ROLE);
160            }
161            catch (ServiceException e)
162            {
163                getLogger().error("Error while retrieving SearchUIModelExtensionPoint", e);
164            }
165        }
166        return _searchUiEP;
167    }
168
169    /**
170     * Getter for ContentTypesHelper to avoid problems at startup
171     * @return ContentTypesHelper
172     */
173    protected ContentTypesHelper getContentTypesHelper()
174    {
175        if (_contentTypesHelper == null)
176        {
177            try
178            {
179                _contentTypesHelper = (ContentTypesHelper) _manager.lookup(ContentTypesHelper.ROLE);
180            }
181            catch (ServiceException e)
182            {
183                getLogger().error("Error while retrieving ContentTypesHelper", e);
184            }
185        }
186        return _contentTypesHelper;
187    }
188
189    /**
190     * Getter for SiteManager to avoid problems at startup
191     * @return SiteManager
192     */
193    protected SiteManager getSiteManager()
194    {
195        if (_siteManager == null)
196        {
197            try
198            {
199                _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE);
200            }
201            catch (ServiceException e)
202            {
203                getLogger().error("Error while retrieving SiteManager", e);
204            }
205        }
206        return _siteManager;
207    }
208
209    /**
210     * Getter for ContentSearcherFactory to avoid problems at startup
211     * @return ContentSearcherFactory
212     */
213    protected ContentSearcherFactory getContentSearcherFactory()
214    {
215        if (_contentSearcherFactory == null)
216        {
217            try
218            {
219                _contentSearcherFactory = (ContentSearcherFactory) _manager.lookup(ContentSearcherFactory.ROLE);
220            }
221            catch (ServiceException e)
222            {
223                getLogger().error("Error while retrieving SiteManager", e);
224            }
225        }
226        return _contentSearcherFactory;
227    }
228    
229
230    public void contextualize(Context context) throws ContextException
231    {
232        _context = context;
233    }
234
235    /**
236     * List all queries available for the mobile app
237     * @return a list of {@link Query}
238     */
239    public List<Query> getQueries()
240    {
241        String queryContainerId = Config.getInstance().getValue(__QUERY_CONTAINER_CONF_ID);
242        
243        AmetysObject ametysObject = getResolver().resolveById(queryContainerId);
244        return getQueries(ametysObject);
245    }
246    
247    /**
248     * Get all queries available in a specific {@link QueryContainer}
249     * @param ametysObject a {@link QueryContainer} to parse, or directly a {@link Query} to return in a list
250     * @return a list containing all {@link Query} available in this {@link QueryContainer}
251     */
252    public List<Query> getQueries(AmetysObject ametysObject)
253    {
254        getLogger().info("Fetch queries under query '{}'", ametysObject.getId());
255        if (ametysObject instanceof QueryContainer)
256        {
257            QueryContainer queryContainer = (QueryContainer) ametysObject;
258            
259            return queryContainer.getChildren().stream()
260                .map(q -> getQueries(q))
261                .flatMap(List::stream)
262                .collect(Collectors.toList());
263        }
264        else if (ametysObject instanceof Query)
265        {
266            getLogger().info("Query '{}' is not a container", ametysObject.getId());
267            return ImmutableList.of((Query) ametysObject);
268        }
269        else
270        {
271            getLogger().info("Query '{}' is empty", ametysObject.getId());
272            return Collections.EMPTY_LIST;
273        }
274    }
275
276    /**
277     * Execute a query, with a forced sort
278     * @param query the query to execute
279     * @param sort a list of sert elements, can contain lastValidation
280     * @param checkRights check the rights while executing the query
281     * @return the results of the query
282     */
283    public AmetysObjectIterable<Content> executeQuery(Query query, List<Sort> sort, boolean checkRights)
284    {
285        getLogger().info("Execute query '{}', testing rights : '{}'", query.getId(), checkRights);
286        try
287        {
288            Long limit = Config.getInstance().getValue(__QUERY_LIMIT_CONF_ID);
289            Map<String, Object> exportParams = getQueriesManager().getExportParams(query);
290            
291            String model = getQueriesManager().getModelForQuery(exportParams);
292            
293            if ("solr".equals(query.getType()))
294            {
295                String queryStr = getQueriesManager().getQueryStringForQuery(exportParams);
296                Set<String> contentTypeIds = getQueriesManager().getContentTypesForQuery(exportParams).stream().collect(Collectors.toSet());
297                AmetysObjectIterable<Content> results = getContentSearcherFactory().create(contentTypeIds)
298                        .setCheckRights(checkRights)
299                        .withSort(sort)
300                        .withLimits(0, limit.intValue())
301                        .search(queryStr);
302                getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize());
303                return results;
304            }
305            else if (Query.Type.SIMPLE.toString().equals(query.getType()) || Query.Type.ADVANCED.toString().equals(query.getType()))
306            {
307                Map<String, Object> values = getQueriesManager().getValuesForQuery(exportParams);
308
309                Map<String, Object> contextualParameters = getQueriesManager().getContextualParametersForQuery(exportParams);
310                String searchMode = getQueriesManager().getSearchModeForQuery(exportParams);
311                SearchUIModel uiModel = getSearchUIModelExtensionPoint().getExtension(model);
312                SearchUIModel wrappedUiModel = new SearchModelWrapper(uiModel, _manager, _context, getLogger());
313                
314                SearchModelContentSearcher searcher = getContentSearcherFactory().create(wrappedUiModel);
315                AmetysObjectIterable<Content> results = searcher
316                        .setCheckRights(checkRights)
317                        .withLimits(0, limit.intValue())
318                        .withSort(sort)
319                        .withSearchMode(searchMode)
320                        .search(values, contextualParameters);
321                getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize());
322                return results;
323            }
324        }
325        catch (Exception e)
326        {
327            getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e);
328        }
329        return null;
330    }
331    
332    /**
333     * Execute all queries to return the list of the queries that return this object
334     * @param content the object to test
335     * @return a list containing all the impacted queries
336     */
337    public List<Query> getQueriesForResult(AmetysObject content)
338    {
339        return getQueriesForResult(content.getId());
340    }
341    
342    /**
343     * Execute all queries to return the list of the queries that return this object
344     * @param contentId the content id
345     * @return a list containing all the impacted queries
346     */
347    public List<Query> getQueriesForResult(String contentId)
348    {
349        Map<String, List<Query>> queriesForResult = getQueriesForResult(List.of(contentId));
350        if (queriesForResult.containsKey(contentId))
351        {
352            return queriesForResult.get(contentId);
353        }
354        else
355        {
356            return Collections.EMPTY_LIST;
357        }
358    }
359
360    /**
361     * Execute all queries to return the list of the queries that return this object
362     * @param contents a list of object to test
363     * @return a map containing, for each content, the list containing all the impacted queries
364     */
365    public Map<String, List<Query>> getQueriesForResult(List<String> contents)
366    {
367        Map<String, List<Query>> result = new HashMap<>();
368        List<Query> queries = getQueries();
369
370        getLogger().info("Searching for queries returning contents : {}", contents);
371        for (Query query : queries)
372        {
373            getLogger().info("Searching with query : {}", query.getId());
374            try
375            {
376                List<Sort> sort = getSortProperty(query, queries.size() > 1);
377                
378                AmetysObjectIterable<Content> searchResults = executeQuery(query, sort, false);
379                if (searchResults != null)
380                {
381                    List<String> resultIds = searchResults.stream().map(c -> c.getId()).collect(Collectors.toList());
382                    
383                    for (String resultId : resultIds)
384                    {
385                        if (contents.contains(resultId))
386                        {
387                            getLogger().info("Query : {} contains content : {}", query.getId(), resultId);
388                            if (!result.containsKey(resultId))
389                            {
390                                result.put(resultId, new ArrayList<>());
391                            }
392                            result.get(resultId).add(query);
393                        }
394                    }
395                }
396            }
397            catch (Exception e)
398            {
399                getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e);
400            }
401        }
402        if (result.isEmpty())
403        {
404            getLogger().info("No query found for contents '{}'", contents);
405        }
406        return result;
407    }
408    
409    /**
410     * Generate a list of sort properties.
411     * If there are multiple queries, they have to be sorted first by date ASC. If they already are, the sort is used, otherwise a sort on lastValidation is used
412     * @param query the query that will be executed
413     * @param moreThanOne if false and the query already contain a sort, the original sort will be returned
414     * @return a list of sort, with the first one by date if moreThanOne is true
415     */
416    public List<Sort> getSortProperty(Query query, boolean moreThanOne)
417    {
418        Map<String, Object> exportParams = getQueriesManager().getExportParams(query);
419        List<Sort> baseSort = getQueriesManager().getSortForQuery(exportParams);
420        if (!moreThanOne && baseSort != null && !baseSort.isEmpty())
421        {
422            return baseSort;
423        }
424        else
425        {
426            if (baseSort != null && !baseSort.isEmpty())
427            {
428                Sort firstSort = baseSort.get(0);
429                Set<String> specificFields = Set.of("creationDate", "lastValidation", "lastModified");
430                if (specificFields.contains(firstSort.getField()))
431                {
432                    firstSort.setOrder(Order.ASC);
433                    return List.of(firstSort);
434                }
435
436                List<String> contentTypesForQuery = getQueriesManager().getContentTypesForQuery(exportParams);
437                if (contentTypesForQuery != null)
438                {
439                    String[] contentTypeIdsAsArray = contentTypesForQuery.toArray(new String[contentTypesForQuery.size()]);
440                    try
441                    {
442                        ModelItem modelItem = getContentTypesHelper().getModelItem(firstSort.getField(), contentTypeIdsAsArray, new String[0]);
443        
444                        String sortFileTypeId = modelItem.getType().getId();
445                        if (ModelItemTypeConstants.DATE_TYPE_ID.equals(sortFileTypeId) || ModelItemTypeConstants.DATETIME_TYPE_ID.equals(sortFileTypeId))
446                        {
447                            firstSort.setOrder(Order.ASC);
448                            return List.of(firstSort);
449                        }
450                    }
451                    catch (UndefinedItemPathException e)
452                    {
453                        getLogger().warn("Error while fetching the model of the field '" + firstSort.getField() + "' for content types [" + String.join(", ", contentTypesForQuery) + "]", e);
454                    }
455                }
456            }
457            Sort sort = new Sort("lastValidation", Sort.Order.ASC);
458            return List.of(sort);
459        }
460    }
461    
462    /**
463     * Get a json object representing the content
464     * @param content tho content to parse
465     * @return a json map
466     */
467    public Map<String, String> getDataForContent(Content content)
468    {
469        Map<String, String> result = new HashMap<>();
470        // feed_id
471        // category_name
472        // date
473        // content_id
474        result.put("content_id", content.getId());
475        if (content.hasValue("illustration/image"))
476        {
477            String imgUri = "illustration/image?contentId=" + content.getId();
478            String image = ResolveURIComponent.resolveCroppedImage("attribute", imgUri, PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true);
479            result.put("image", image);
480        }
481        // title
482        result.put("title", content.getTitle());
483        // content_url
484        if (content instanceof WebContent)
485        {
486            WebContent webContent = (WebContent) content;
487            Iterator<Page> pages = webContent.getReferencingPages().iterator();
488            if (pages.hasNext())
489            {
490                Page page = pages.next();
491                String siteName = webContent.getSiteName();
492                String url = getSiteManager().getSite(siteName).getUrl() + "/" + page.getSitemap().getName() + "/" + page.getPathInSitemap() + ".html";
493                result.put("content_url", url);
494            }
495            
496        }
497        return result;
498    }
499
500    /**
501     * Transform a ZonedDateTime, LocalDate or Date to an instant
502     * @param o1 the date to transform
503     * @param o2 a relative date, if it contains a timezone and the 1st does not, this timezone will bu used
504     * @return an Instant
505     */
506    public Instant toInstant(Object o1, Object o2)
507    {
508        if (o1 instanceof ZonedDateTime)
509        {
510            return ((ZonedDateTime) o1).toInstant();
511        }
512        else if (o1 instanceof LocalDate)
513        {
514            if (o2 instanceof ZonedDateTime)
515            {
516                LocalDate ld1 = (LocalDate) o1;
517                ZonedDateTime zdt2 = (ZonedDateTime) o2;
518                return ld1.atStartOfDay(zdt2.getZone()).toInstant();
519            }
520            else
521            {
522                return ((LocalDate) o1).atStartOfDay(ZoneId.systemDefault()).toInstant();
523            }
524        }
525        else if (o1 instanceof Date)
526        {
527            return ((Date) o1).toInstant();
528        }
529
530        return null;
531    }
532    
533    /**
534     * Get the date of the content, can be the date requested in the field, or by default the last validation date
535     * @param content the content to read
536     * @param field the field to check
537     * @return the iso formatted date
538     */
539    public String getContentFormattedDate(Content content, String field)
540    {
541        String isoDate = null;
542        if (StringUtils.isNotBlank(field) && content.hasValue(field))
543        {
544            Object value = content.getValue(field, true, null);
545            if (value instanceof Date)
546            {
547                isoDate = DateUtils.dateToString((Date) value);
548            }
549            else
550            {
551                Instant instant = toInstant(value, null);
552                if (instant != null)
553                {
554                    Date date = new Date(instant.toEpochMilli());
555                    isoDate = DateUtils.dateToString(date);
556                }
557            }
558        }
559        else if ("lastModified".equals(field))
560        {
561            // lastModified is not a content field
562            isoDate = DateUtils.zonedDateTimeToString(content.getLastModified());
563        }
564        
565        // If no date found, use the last validation date
566        if (StringUtils.isBlank(isoDate))
567        {
568            isoDate = DateUtils.zonedDateTimeToString(content.getLastValidationDate());
569        }
570        
571        return isoDate;
572    }
573}