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