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