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