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