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        if (StringUtils.isBlank(queryContainerId))
248        {
249            return List.of();
250        }
251        
252        AmetysObject ametysObject = getResolver().resolveById(queryContainerId);
253        Stream<Query> queries = getQueries(ametysObject);
254        
255        if (notificationOnly)
256        {
257            return queries.toList();
258        }
259        
260        queryContainerId = site != null ? site.getValue(QUERY_FEED_CONTAINER_SITE_CONF_ID) : null;
261        
262        if (StringUtils.isBlank(queryContainerId))
263        {
264            queryContainerId = Config.getInstance().getValue(QUERY_FEED_CONTAINER_CONF_ID);
265        }
266        
267        if (StringUtils.isNotBlank(queryContainerId))
268        {
269            ametysObject = getResolver().resolveById(queryContainerId);
270            return Stream.concat(queries, getQueries(ametysObject)).distinct().toList();
271        }
272        
273        return queries.toList();
274    }
275    
276    /**
277     * Get all queries available in a specific {@link QueryContainer}
278     * @param ametysObject a {@link QueryContainer} to parse, or directly a {@link Query} to return in a list
279     * @return a list containing all {@link Query} available in this {@link QueryContainer}
280     */
281    public Stream<Query> getQueries(AmetysObject ametysObject)
282    {
283        getLogger().info("Fetch queries under query '{}'", ametysObject.getId());
284        if (ametysObject instanceof QueryContainer)
285        {
286            QueryContainer queryContainer = (QueryContainer) ametysObject;
287
288            return queryContainer.getChildren().stream()
289                .flatMap(q -> getQueries(q));
290        }
291        else if (ametysObject instanceof Query)
292        {
293            getLogger().info("Query '{}' is not a container", ametysObject.getId());
294            return Stream.of((Query) ametysObject);
295        }
296        else
297        {
298            getLogger().info("Query '{}' is empty", ametysObject.getId());
299            return Stream.of();
300        }
301    }
302
303    /**
304     * Execute a query, with a forced sort
305     * @param query the query to execute
306     * @param sort a list of sort elements, can contain lastValidation
307     * @param checkRights check the rights while executing the query
308     * @param limit the max number of result, -1 for no limit
309     * @return the results of the query
310     */
311    public AmetysObjectIterable<Content> executeQuery(Query query, List<ContentSearchSort> sort, boolean checkRights, int limit)
312    {
313        getLogger().info("Execute query '{}', testing rights : '{}'", query.getId(), checkRights);
314        try
315        {
316            Map<String, Object> exportParams = getQueriesManager().getExportParams(query);
317
318            String model = getQueriesManager().getModelForQuery(exportParams);
319
320            if ("solr".equals(query.getType()))
321            {
322                String queryStr = getQueriesManager().getQueryStringForQuery(exportParams);
323                Set<String> contentTypeIds = getQueriesManager().getContentTypesForQuery(exportParams).stream().collect(Collectors.toSet());
324                SimpleContentSearcher searcher = getContentSearcherFactory().create(contentTypeIds)
325                        .setCheckRights(checkRights)
326                        .withSort(sort);
327                if (limit != -1)
328                {
329                    searcher.withLimits(0, limit);
330                }
331                AmetysObjectIterable<Content> results = searcher.search(queryStr);
332                getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize());
333                return results;
334            }
335            else if (Query.Type.SIMPLE.toString().equals(query.getType()) || Query.Type.ADVANCED.toString().equals(query.getType()))
336            {
337                Map<String, Object> values = getQueriesManager().getValuesForQuery(exportParams);
338
339                Map<String, Object> contextualParameters = getQueriesManager().getContextualParametersForQuery(exportParams);
340                String searchMode = getQueriesManager().getSearchModeForQuery(exportParams);
341                SearchUIModel uiModel = getSearchUIModelExtensionPoint().getExtension(model);
342                SearchUIModel wrappedUiModel = new SearchModelWrapper(uiModel, _manager, getLogger());
343
344                SearchModelContentSearcher searcher = getContentSearcherFactory().create(wrappedUiModel)
345                    .withSort(sort)
346                    .withSearchMode(searchMode)
347                    .setCheckRights(checkRights);
348                if (limit != -1)
349                {
350                    searcher.withLimits(0, limit);
351                }
352                AmetysObjectIterable<Content> results = searcher
353                        .search(values, contextualParameters);
354                getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize());
355                return results;
356            }
357        }
358        catch (Exception e)
359        {
360            getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e);
361        }
362        return null;
363    }
364
365    /**
366     * Execute all queries to return the list of the queries that return this object
367     * @param content the object to test
368     * @param site the site. May be null
369     * @param notificationOnly true to only retrieve queries used for notification
370     * @param limit the max number of result for each query, -1 for no limit
371     * @return a list containing all the impacted queries
372     */
373    public Set<Query> getQueriesForResult(AmetysObject content, Site site, boolean notificationOnly, int limit)
374    {
375        return getQueriesForResult(content.getId(), site, notificationOnly, limit);
376    }
377
378    /**
379     * Execute all queries to return the list of the queries that return this object
380     * @param contentId the content id
381     * @param site the site. May be null
382     * @param notificationOnly true to only retrieve queries used for notification
383     * @param limit the max number of result for each query, -1 for no limit
384     * @return a list containing all the impacted queries
385     */
386    public Set<Query> getQueriesForResult(String contentId, Site site, boolean notificationOnly, int limit)
387    {
388        Map<String, Set<Query>> queriesForResult = getQueriesForResult(List.of(contentId), site, notificationOnly, limit);
389        if (queriesForResult.containsKey(contentId))
390        {
391            return queriesForResult.get(contentId);
392        }
393        else
394        {
395            return Collections.EMPTY_SET;
396        }
397    }
398
399    /**
400     * Execute all queries to return the list of the queries that return this object
401     * @param contents a list of object to test
402     * @param site the site. May be null
403     * @param notificationOnly true to only retrieve queries used for notification
404     * @param limit the max number of result for each query, -1 for no limit
405     * @return a map containing, for each content, the list containing all the impacted queries
406     */
407    public Map<String, Set<Query>> getQueriesForResult(List<String> contents, Site site, boolean notificationOnly, int limit)
408    {
409        Map<String, Set<Query>> result = new HashMap<>();
410        List<Query> queries = getQueries(site, notificationOnly);
411
412        getLogger().info("Searching for queries returning contents : {}", contents);
413        for (Query query : queries)
414        {
415            getLogger().info("Searching with query : {}", query.getId());
416            try
417            {
418                List<ContentSearchSort> sort = getSortProperty(query, queries.size() > 1);
419
420                AmetysObjectIterable<Content> searchResults = executeQuery(query, sort, false, limit);
421                if (searchResults != null)
422                {
423                    List<String> resultIds = searchResults.stream().map(c -> c.getId()).collect(Collectors.toList());
424
425                    for (String resultId : resultIds)
426                    {
427                        if (contents.contains(resultId))
428                        {
429                            getLogger().info("Query : {} contains content : {}", query.getId(), resultId);
430                            if (!result.containsKey(resultId))
431                            {
432                                result.put(resultId, new HashSet<>());
433                            }
434                            result.get(resultId).add(query);
435                        }
436                    }
437                }
438            }
439            catch (Exception e)
440            {
441                getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e);
442            }
443        }
444        if (result.isEmpty())
445        {
446            getLogger().info("No query found for contents '{}'", contents);
447        }
448        return result;
449    }
450
451    /**
452     * Generate a list of sort properties.
453     * 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
454     * @param query the query that will be executed
455     * @param moreThanOne if false and the query already contain a sort, the original sort will be returned
456     * @return a list of sort, with the first one by date if moreThanOne is true
457     */
458    public List<ContentSearchSort> getSortProperty(Query query, boolean moreThanOne)
459    {
460        Map<String, Object> exportParams = getQueriesManager().getExportParams(query);
461        List<ContentSearchSort> baseSort = getQueriesManager().getSortForQuery(exportParams);
462        if (!moreThanOne && baseSort != null && !baseSort.isEmpty())
463        {
464            return baseSort;
465        }
466        else
467        {
468            if (baseSort != null && !baseSort.isEmpty())
469            {
470                ContentSearchSort firstSort = baseSort.get(0);
471                Set<String> specificFields = Set.of("creationDate", "lastValidation", "lastModified");
472                if (specificFields.contains(firstSort.sortField()))
473                {
474                    return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC));
475                }
476
477                List<String> contentTypesForQuery = getQueriesManager().getContentTypesForQuery(exportParams);
478                if (contentTypesForQuery != null)
479                {
480                    String[] contentTypeIdsAsArray = contentTypesForQuery.toArray(new String[contentTypesForQuery.size()]);
481                    try
482                    {
483                        ModelItem modelItem = getContentTypesHelper().getModelItem(firstSort.sortField(), contentTypeIdsAsArray, new String[0]);
484
485                        String sortFileTypeId = modelItem.getType().getId();
486                        if (ModelItemTypeConstants.DATE_TYPE_ID.equals(sortFileTypeId) || ModelItemTypeConstants.DATETIME_TYPE_ID.equals(sortFileTypeId))
487                        {
488                            return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC));
489                        }
490                    }
491                    catch (UndefinedItemPathException e)
492                    {
493                        getLogger().warn("Error while fetching the model of the field '" + firstSort.sortField() + "' for content types [" + String.join(", ", contentTypesForQuery) + "]", e);
494                    }
495                }
496            }
497            ContentSearchSort sort = new ContentSearchSort("lastValidation", SortOrder.ASC);
498            return List.of(sort);
499        }
500    }
501
502    /**
503     * Get a json object representing the content
504     * @param content tho content to parse
505     * @return a json map
506     */
507    public Map<String, String> getDataForContent(Content content)
508    {
509        Map<String, String> result = new HashMap<>();
510        // feed_id
511        // category_name
512        // date
513        // content_id
514        result.put("content_id", content.getId());
515        if (content.hasValue("illustration/image"))
516        {
517            String image = null;
518            
519            Object value = content.getValue("illustration/image");
520            if (value instanceof ExplorerFile file)
521            {
522                image = ResolveURIComponent.resolveCroppedImage("explorer", file.getResourceId(), PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true);
523            }
524            else
525            {
526                String imgUri = "illustration/image?contentId=" + content.getId();
527                image = ResolveURIComponent.resolveCroppedImage("attribute", imgUri, PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true);
528            }
529            
530            if (image != null)
531            {
532                result.put("image", image);
533            }
534        }
535        // title
536        result.put("title", content.getTitle());
537        // content_url
538        if (content instanceof WebContent)
539        {
540            WebContent webContent = (WebContent) content;
541            Iterator<Page> pages = webContent.getReferencingPages().iterator();
542            if (pages.hasNext())
543            {
544                Page page = pages.next();
545                String siteName = webContent.getSiteName();
546                String url = getSiteManager().getSite(siteName).getUrl() + "/" + page.getSitemap().getName() + "/" + page.getPathInSitemap() + ".html";
547                result.put("content_url", url);
548            }
549
550        }
551        return result;
552    }
553
554    /**
555     * Transform a ZonedDateTime, LocalDate or Date to an instant
556     * @param o1 the date to transform
557     * @param o2 a relative date, if it contains a timezone and the 1st does not, this timezone will bu used
558     * @return an Instant
559     */
560    public Instant toInstant(Object o1, Object o2)
561    {
562        if (o1 instanceof ZonedDateTime)
563        {
564            return ((ZonedDateTime) o1).toInstant();
565        }
566        else if (o1 instanceof LocalDate)
567        {
568            if (o2 instanceof ZonedDateTime)
569            {
570                LocalDate ld1 = (LocalDate) o1;
571                ZonedDateTime zdt2 = (ZonedDateTime) o2;
572                return ld1.atStartOfDay(zdt2.getZone()).toInstant();
573            }
574            else
575            {
576                return ((LocalDate) o1).atStartOfDay(ZoneId.systemDefault()).toInstant();
577            }
578        }
579        else if (o1 instanceof Date)
580        {
581            return ((Date) o1).toInstant();
582        }
583
584        return null;
585    }
586
587    /**
588     * Get the date of the content, can be the date requested in the field, or by default the last validation date
589     * @param content the content to read
590     * @param field the field to check
591     * @return the iso formatted date
592     */
593    public String getContentFormattedDate(Content content, String field)
594    {
595        String isoDate = null;
596        if (StringUtils.isNotBlank(field) && content.hasValue(field))
597        {
598            Object value = content.getValue(field, true, null);
599            if (value instanceof Date)
600            {
601                isoDate = DateUtils.dateToString((Date) value);
602            }
603            else
604            {
605                Instant instant = toInstant(value, null);
606                if (instant != null)
607                {
608                    Date date = new Date(instant.toEpochMilli());
609                    isoDate = DateUtils.dateToString(date);
610                }
611            }
612        }
613        else if ("lastModified".equals(field))
614        {
615            // lastModified is not a content field
616            isoDate = DateUtils.zonedDateTimeToString(content.getLastModified());
617        }
618
619        // If no date found, use the last validation date
620        if (StringUtils.isBlank(isoDate))
621        {
622            isoDate = DateUtils.zonedDateTimeToString(content.getLastValidationDate());
623        }
624
625        return isoDate;
626    }
627}