001/*
002 *  Copyright 2015 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.web.frontoffice;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.time.ZonedDateTime;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.Date;
026import java.util.Enumeration;
027import java.util.List;
028import java.util.Locale;
029import java.util.Map;
030import java.util.Map.Entry;
031import java.util.Objects;
032import java.util.Set;
033import java.util.regex.Pattern;
034import java.util.stream.Collectors;
035
036import org.apache.avalon.framework.context.Context;
037import org.apache.avalon.framework.context.ContextException;
038import org.apache.avalon.framework.context.Contextualizable;
039import org.apache.avalon.framework.service.ServiceException;
040import org.apache.avalon.framework.service.ServiceManager;
041import org.apache.cocoon.ProcessingException;
042import org.apache.cocoon.environment.ObjectModelHelper;
043import org.apache.cocoon.environment.Request;
044import org.apache.cocoon.generation.ServiceableGenerator;
045import org.apache.cocoon.xml.AttributesImpl;
046import org.apache.cocoon.xml.XMLUtils;
047import org.apache.commons.lang.StringUtils;
048import org.apache.excalibur.xml.sax.SAXParser;
049import org.apache.solr.client.solrj.util.ClientUtils;
050import org.apache.tika.Tika;
051import org.slf4j.Logger;
052import org.xml.sax.InputSource;
053import org.xml.sax.SAXException;
054
055import org.ametys.cms.content.RichTextHandler;
056import org.ametys.cms.contenttype.ContentType;
057import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
058import org.ametys.cms.contenttype.ContentTypesHelper;
059import org.ametys.cms.data.RichText;
060import org.ametys.cms.data.type.ModelItemTypeConstants;
061import org.ametys.cms.repository.Content;
062import org.ametys.cms.search.SearchField;
063import org.ametys.cms.search.SearchResults;
064import org.ametys.cms.search.Sort;
065import org.ametys.cms.search.content.ContentSearchHelper;
066import org.ametys.cms.search.query.DocumentTypeQuery;
067import org.ametys.cms.search.query.OrQuery;
068import org.ametys.cms.search.query.Query;
069import org.ametys.cms.search.solr.SearcherFactory.Searcher;
070import org.ametys.cms.tag.TagProviderExtensionPoint;
071import org.ametys.cms.transformation.URIResolverExtensionPoint;
072import org.ametys.core.util.DateUtils;
073import org.ametys.core.util.FilenameUtils;
074import org.ametys.core.util.LambdaUtils;
075import org.ametys.core.util.URIUtils;
076import org.ametys.plugins.explorer.resources.Resource;
077import org.ametys.plugins.explorer.resources.metadata.TikaProvider;
078import org.ametys.plugins.repository.AmetysObject;
079import org.ametys.plugins.repository.AmetysObjectIterable;
080import org.ametys.plugins.repository.AmetysObjectResolver;
081import org.ametys.plugins.repository.AmetysRepositoryException;
082import org.ametys.plugins.repository.UnknownAmetysObjectException;
083import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
084import org.ametys.runtime.i18n.I18nizableText;
085import org.ametys.runtime.model.ElementDefinition;
086import org.ametys.runtime.model.ModelItem;
087import org.ametys.runtime.model.View;
088import org.ametys.runtime.model.ViewHelper;
089import org.ametys.runtime.model.type.DataContext;
090import org.ametys.web.WebConstants;
091import org.ametys.web.frontoffice.FrontOfficeSearcherFactory.QueryFacet;
092import org.ametys.web.indexing.solr.SolrWebFieldNames;
093import org.ametys.web.repository.page.Page;
094import org.ametys.web.repository.page.Page.PageType;
095import org.ametys.web.repository.page.Zone;
096import org.ametys.web.repository.page.ZoneItem;
097import org.ametys.web.repository.page.ZoneItem.ZoneType;
098import org.ametys.web.repository.site.Site;
099import org.ametys.web.repository.site.SiteManager;
100
101/**
102 * Abstract class for solr search
103 */
104public abstract class AbstractSearchGenerator extends ServiceableGenerator implements Contextualizable, SolrWebFieldNames
105{
106    /** The name of the facet.query testing the pageResources */
107    public static final String DOCUMENT_TYPE_IS_PAGE_RESOURCE_FACET_NAME = "isPageResource";
108    
109    /** Textfield pattern */
110    protected static final Pattern _TEXTFIELD_PATTERN = Pattern.compile("^[^?*].*$");
111    
112    /** The {@link ContentType} manager */
113    protected ContentTypeExtensionPoint _cTypeExtPt;
114    /** The sites manager */
115    protected SiteManager _siteManager;
116    /** The cocoon context */
117    protected org.apache.cocoon.environment.Context _context;
118    /** The tag extension point */
119    protected TagProviderExtensionPoint _tagExtPt;
120    /** The Ametys resolver */
121    protected AmetysObjectResolver _resolver;
122    /** The helper to handler content types */
123    protected ContentTypesHelper _contentTypesHelper;
124    /** The uri resolver extension point */
125    protected URIResolverExtensionPoint _uriResolverEP;
126    /** The searcher factory */
127    protected FrontOfficeSearcherFactory _searcherFactory;
128    /** The content searcher */
129    protected ContentSearchHelper _searchHelper;
130    
131    @Override
132    public void service(ServiceManager smanager) throws ServiceException
133    {
134        super.service(smanager);
135        _cTypeExtPt = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
136        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
137        _tagExtPt = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
138        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
139        _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
140        _uriResolverEP = (URIResolverExtensionPoint) smanager.lookup(URIResolverExtensionPoint.ROLE);
141        _searcherFactory = (FrontOfficeSearcherFactory) smanager.lookup(FrontOfficeSearcherFactory.ROLE);
142        _searchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE);
143    }
144
145    @Override
146    public void contextualize(Context context) throws ContextException
147    {
148        _context = (org.apache.cocoon.environment.Context) context.get(org.apache.cocoon.Constants.CONTEXT_ENVIRONMENT_CONTEXT);
149    }
150
151    @Override
152    public void generate() throws IOException, SAXException, ProcessingException
153    {
154        Request request = ObjectModelHelper.getRequest(objectModel);
155        
156        String currentSiteName = null;
157        String lang = null;
158        Page page = (Page) request.getAttribute(WebConstants.REQUEST_ATTR_PAGE);
159        if (page != null)
160        {
161            currentSiteName = page.getSiteName();
162            lang = page.getSitemapName();
163        }
164        else
165        {
166            currentSiteName = parameters.getParameter("siteName", request.getParameter("siteName"));
167            lang = parameters.getParameter("lang", request.getParameter("lang"));
168        }
169
170        int pageIndex = getPageIndex(request);
171        // TODO Rename to maxResults
172        int maxResults = parameters.getParameterAsInteger("offset", 10);
173        int start = (pageIndex - 1) * maxResults;
174
175        String[] sites = request.getParameterValues("sites");
176        List<String> siteNames = new ArrayList<>();
177        if (sites != null && sites.length > 0 && !(sites.length == 1 && sites[0].equals("")))
178        {
179            for (String site : sites)
180            {
181                siteNames.add(site);
182            }
183        }
184        else
185        {
186            siteNames.add(currentSiteName);
187        }
188        
189        contentHandler.startDocument();
190
191        AttributesImpl attrs = new AttributesImpl();
192        attrs.addCDATAAttribute("site", currentSiteName);
193        attrs.addCDATAAttribute("lang", lang);
194
195        XMLUtils.startElement(contentHandler, "search", attrs);
196
197        saxServiceIdentifiers();
198        saxAdditionalInfos();
199
200        // The search url
201        XMLUtils.createElement(contentHandler, "url", page != null ? lang + "/" + page.getPathInSitemap() + ".html" : lang + "/_plugins/" + currentSiteName + "/" + lang + "/service/search-pages.html");
202
203        // Display the form and results on same page?
204        String searchMode = getSearchMode();
205        XMLUtils.createElement(contentHandler, "search-mode", searchMode);
206
207        try
208        {
209            SearchResults<AmetysObject> searchResults = null;
210            boolean submit = request.getParameter("submit-form") != null;
211            boolean criteriaOnly = "criteria-only".equals(getSearchMode());
212            if (submit && isInputValid() && !criteriaOnly)
213            {
214                searchResults = search(request, siteNames, lang, pageIndex, start, maxResults);
215            }
216            else if (!getFacets(request).isEmpty())
217            {
218                searchResults = search(request, siteNames, lang, pageIndex, start, maxResults, false);
219            }
220            
221            saxFormParameters(request, searchResults, start, maxResults, currentSiteName, lang);
222        }
223        catch (IllegalArgumentException e)
224        {
225            getLogger().error("The search field is invalid", e);
226            XMLUtils.createElement(contentHandler, "illegal-textfield");
227            saxPagination(0, start, maxResults);
228        }
229        catch (Exception e)
230        {
231            getLogger().error("Unable to search", e);
232            saxPagination(0, start, maxResults);
233        }
234        
235        XMLUtils.endElement(contentHandler, "search");
236        contentHandler.endDocument();
237    }
238
239    /**
240     * Get the zone item
241     * @param request The request
242     * @return the zone item
243     */
244    protected ZoneItem getZoneItem(Request request)
245    {
246        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
247        if (zoneItem != null)
248        {
249            return zoneItem;
250        }
251        
252        String zoneItemId = parameters.getParameter("zoneItemId", request.getParameter("zone-item-id"));
253        if (StringUtils.isNotEmpty(zoneItemId))
254        {
255            try
256            {
257                return _resolver.resolveById(zoneItemId);
258            }
259            catch (UnknownAmetysObjectException e)
260            {
261                return null;
262            }
263        }
264        
265        return null;
266    }
267    
268    /**
269     * Search
270     * @param request the request
271     * @param siteNames The name of the sites to search in 
272     * @param language The language code to search
273     * @param pageIndex the page index
274     * @param start The offset for search results
275     * @param maxResults The maximum number of results
276     * @return The search results
277     * @throws Exception If an error occurred during search
278     */
279    protected SearchResults<AmetysObject> search(Request request, Collection<String> siteNames, String language, int pageIndex, int start, int maxResults) throws Exception
280    {
281        return search(request, siteNames, language, pageIndex, start, maxResults, true);
282    }
283    
284    /**
285     * Search
286     * @param request the request
287     * @param siteNames The name of the sites to search in 
288     * @param language The language code to search
289     * @param pageIndex the page index
290     * @param start The offset for search results
291     * @param maxResults The maximum number of results
292     * @param saxResults false to not sax results
293     * @return The search results
294     * @throws Exception If an error occurred during search
295     */
296    protected SearchResults<AmetysObject> search(Request request, Collection<String> siteNames, String language, int pageIndex, int start, int maxResults, boolean saxResults) throws Exception
297    {
298        // Retrieve current workspace
299        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
300        
301        SearchResults<AmetysObject> results = null;
302        
303        try
304        {
305            // Query
306            Query queryObject = getQuery(request, siteNames, language);
307            
308            // Filter queries
309            Collection<Query> filterQueries = getFilterQueries(request, siteNames, language);
310            // Document types query
311            Collection<String> documentTypes = getDocumentTypes(request);
312            Query documentTypesQuery = getDocumentTypesQuery(documentTypes);
313            
314            // Get first sort field
315            Sort sort = getSortField(request);
316            saxSort(sort);
317            
318            Searcher searcher = _searcherFactory.create()
319                    .withQuery(queryObject)
320                    .withFilterQueries(filterQueries)
321                    .addFilterQuery(documentTypesQuery)
322                    .withLimits(0, Integer.MAX_VALUE)
323                    .withSort(getPrimarySortFields(request))
324                    .addSort(sort)
325                    .setCheckRights(_checkRights());
326            
327            _additionalSearchProcessing(searcher);
328            
329            // Facets 
330            Collection<SearchField> facets = getFacets(request).values().stream().map(f -> f.getSearchField()).collect(Collectors.toList());
331            if (!facets.isEmpty())
332            {
333                searcher.withFacets(facets)
334                        .withFacetValues(getFacetValues(request, siteNames, language));
335            }
336            
337            try
338            {
339                results = searcher.searchWithFacets();
340            }
341            catch (Exception e)
342            {
343                getLogger().error("An error occured with Solr query", e);
344            }
345            
346            if (saxResults)
347            {
348                // SAX results
349                AttributesImpl atts = new AttributesImpl();
350                long total = results != null ? results.getResults().getSize() : 0;
351                atts.addCDATAAttribute("total", String.valueOf(total));
352                XMLUtils.startElement(contentHandler, "hits", atts);
353                if (results != null)
354                {
355                    saxHits(results, start, maxResults);
356                }
357                XMLUtils.endElement(contentHandler, "hits");
358        
359                // SAX pagination
360                saxPagination(total, start, maxResults);
361            }
362        }
363        finally
364        {
365            // Restore context
366            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
367        }
368        
369        return results;
370    }
371    
372    /**
373     * SAX sort
374     * @param sort the current sort
375     * @throws SAXException SAXException If an error occurs while SAXing
376     */
377    protected void saxSort(Sort sort) throws SAXException
378    {
379        XMLUtils.createElement(contentHandler, sort == null || sort.getField() == null ? "sort-by-score" : "sort-by-" + sort.getField());
380    }
381    
382    /**
383     * Allow to perform other searcher configuration
384     * @param searcher The searcher that will be used for the search operation
385     */
386    protected void _additionalSearchProcessing(Searcher searcher)
387    {
388        // Nothing by default
389    }
390    
391    /**
392     * Build the search query to have results matching at least one of the given document types
393     * @param documentTypes The document types
394     * @return The document type query
395     */
396    protected Query getDocumentTypesQuery(Collection<String> documentTypes)
397    {
398        List<Query> queries = documentTypes.stream()
399                .map(t -> "\"" + ClientUtils.escapeQueryChars(t) + "\"")
400                .map(DocumentTypeQuery::new)
401                .collect(Collectors.toList());
402        return new OrQuery(queries);
403    }
404    
405    /**
406     * Get the document types.
407     * @param request The request.
408     * @return the document types to search.
409     */
410    protected abstract Collection<String> getDocumentTypes(Request request);
411    
412    /**
413     * Get the sort field
414     * @param request The request
415     * @return The sort field or null to sort by score
416     */
417    protected abstract Sort getSortField(Request request);
418    
419    /**
420     * Get the primary sort fields
421     * @param request The request
422     * @return the list additional sort fields or empty list.
423    */
424    protected abstract List<Sort> getPrimarySortFields(Request request);
425    
426    /**
427     * SAX the search parameters from the request parameters
428     * @param request The request
429     * @param searchResults The search results
430     * @param start The start index
431     * @param offset The number of results
432     * @param siteName The current site name
433     * @param lang The current language
434     * @throws SAXException If an error occurs while SAXing
435     */
436    protected void saxFormParameters (Request request, SearchResults<AmetysObject> searchResults, int start, int offset, String siteName, String lang) throws SAXException
437    {
438        XMLUtils.startElement(contentHandler, "form");
439
440        XMLUtils.startElement(contentHandler, "fields");
441        saxFormFields(request, siteName, lang);
442        XMLUtils.endElement(contentHandler, "fields");
443
444        saxFacets(request, searchResults, siteName, lang);
445        
446        boolean submit = request.getParameter("submit-form") != null;
447        if (submit)
448        {
449            if (isInputValid())
450            {
451                XMLUtils.startElement(contentHandler, "values");
452
453                XMLUtils.createElement(contentHandler, "start", String.valueOf(start));
454                XMLUtils.createElement(contentHandler, "offset", String.valueOf(offset));
455
456                saxFormValues(request, start, offset);
457
458                XMLUtils.endElement(contentHandler, "values");
459            }
460        }
461        XMLUtils.endElement(contentHandler, "form");
462    }
463
464    /**
465     * SAX the form search criteria
466     * @param request The request
467     * @param siteName The current site name
468     * @param lang The current language
469     * @throws SAXException if an error occurs while SAXing
470     */
471    protected abstract void saxFormFields(Request request, String siteName, String lang) throws SAXException;
472
473    /**
474     * SAX the facets results
475     * @param request The request
476     * @param searchResults The search result
477     * @param siteName The site name
478     * @param lang The language
479     * @throws SAXException if an error occurred while saxing
480     */
481    protected void saxFacets(Request request, SearchResults<AmetysObject> searchResults, String siteName, String lang) throws SAXException
482    {
483        XMLUtils.startElement(contentHandler, "facets");
484        
485        Map<String, Map<String, Integer>> facetResults = searchResults != null ? searchResults.getFacetResults() : Collections.EMPTY_MAP;
486        if (!facetResults.isEmpty())
487        {
488            Map<String, FacetField> facets = getFacets(request);
489            
490            for (String fieldName : facets.keySet())
491            {
492                if (facetResults.containsKey(SolrWebFieldNames.FACETABLE_CONTENT_FIELD_PREFIX + fieldName))
493                {
494                    Map<String, Integer> values = facetResults.get(SolrWebFieldNames.FACETABLE_CONTENT_FIELD_PREFIX + fieldName);
495                    
496                    AttributesImpl attr = new AttributesImpl();
497                    attr.addCDATAAttribute("name", fieldName);
498                    attr.addCDATAAttribute("total", String.valueOf(values.values().stream().mapToInt(Integer::intValue).sum()));
499                    XMLUtils.startElement(contentHandler, "facet", attr);
500                    
501                    facets.get(fieldName).getLabel().toSAX(contentHandler, "label");
502                    
503                    Set<Entry<String, Integer>> entrySet = values.entrySet();
504                    for (Entry<String, Integer> entry : entrySet)
505                    {
506                        AttributesImpl valueAttrs = new AttributesImpl();
507                        valueAttrs.addCDATAAttribute("value", entry.getKey());
508                        valueAttrs.addCDATAAttribute("count", Integer.toString(entry.getValue()));
509                        
510                        XMLUtils.startElement(contentHandler, "item", valueAttrs);
511                        facets.get(fieldName).getFacetLabel(entry.getKey(), new Locale(lang)).toSAX(contentHandler);
512                        XMLUtils.endElement(contentHandler, "item");
513                    }
514                    
515                    XMLUtils.endElement(contentHandler, "facet");
516                }
517                
518            }
519        }
520        
521        XMLUtils.endElement(contentHandler, "facets");
522    }
523
524    /**
525     * SAX the form search criteria values
526     * @param request The request
527     * @param start The start index
528     * @param offset The number of results
529     * @throws SAXException if an error occurs while SAXing
530     */
531    protected abstract void saxFormValues (Request request, int start, int offset) throws SAXException;
532
533    /**
534     * Get the query from request parameters
535     * @param request The request
536     * @param siteNames The site names.
537     * @param language The language
538     * @return The query object.
539     * @throws IllegalArgumentException If the search field is invalid.
540     */
541    protected abstract Query getQuery(Request request, Collection<String> siteNames, String language) throws IllegalArgumentException;
542    
543    /**
544     * Get the filter queries from the request parameters.
545     * @param request The request.
546     * @param siteNames The site names.
547     * @param language The language.
548     * @return A collection of filter queries.
549     * @throws IllegalArgumentException If a search field is invalid.
550     */
551    protected abstract Collection<Query> getFilterQueries(Request request, Collection<String> siteNames, String language) throws IllegalArgumentException;
552    
553    /**
554     * Template methods to disable/enable the processing of the facets during the search.
555     * @return <code>true</code> to enable facets
556     */
557    protected boolean useFacets()
558    {
559        return parameters.getParameterAsBoolean("facets", false);
560    }
561    
562    /**
563     * Get the facets from request parameters
564     * @param request The request
565     * @return The facet fields
566     * @throws IllegalArgumentException If the search field is invalid.
567     */
568    protected abstract Map<String, FacetField> getFacets(Request request) throws IllegalArgumentException;
569    
570    /**
571     * Get the facet.queries
572     * @param request The request
573     * @return The facet.queries
574     * @throws IllegalArgumentException If the search field is invalid.
575     */
576    protected abstract Set<QueryFacet> getQueryFacets(Request request);
577
578    /**
579     * Get the facet values
580     * @param request The request
581     * @param siteNames The site names
582     * @param language The language
583     * @return The facet values
584     */
585    protected abstract Map<String, List<String>> getFacetValues(Request request, Collection<String> siteNames, String language);
586    
587    /**
588     * Get the facet.query values
589     * @param request The request
590     * @return The facet.query values
591     */
592    protected abstract Collection<String> getQueryFacetValues(Request request);
593    
594    /**
595     * SAX the result hits
596     * @param results The search results.
597     * @param start The start index
598     * @param maxResults The number of results to generate.
599     * @throws SAXException If an error occurs while SAXing
600     * @throws IOException If there is a low-level IO error
601     */
602    protected abstract void saxHits(SearchResults<AmetysObject> results, int start, int maxResults) throws SAXException, IOException;
603
604    /**
605     * Get the searchable fields
606     * @return The fields
607     */
608    protected abstract Collection<String> getFields();
609
610    /**
611     * SAX a hit of type page.
612     * @param score The score of the page
613     * @param maxScore The maximum score of the search results
614     * @param page The page
615     * @throws SAXException If an error occurs while SAXing
616     */
617    protected void saxPageHit(float score, float maxScore, Page page) throws SAXException
618    {
619        int percent = Math.min(Math.round(score * 100f / maxScore), 100);
620        
621        XMLUtils.startElement(contentHandler, "hit");
622        XMLUtils.createElement(contentHandler, "score", Float.toString(score));
623        XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent));
624        XMLUtils.createElement(contentHandler, "title", page.getTitle());
625        
626        _saxPageContents(page);
627        
628        XMLUtils.createElement(contentHandler, "type", "page");
629        XMLUtils.createElement(contentHandler, "uri", page.getSitemap().getName() + "/" + page.getPathInSitemap());
630        
631        _saxLastModifiedDate(page);
632        _saxLastValidationDate(page);
633        
634        String siteName = page.getSiteName();
635        if (siteName != null)
636        {
637            Site site = _siteManager.getSite(siteName);
638            XMLUtils.createElement(contentHandler, "siteName", siteName);
639            XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle());
640            String url = site.getUrl();
641            if (url != null)
642            {
643                XMLUtils.createElement(contentHandler, "siteUrl", url);
644            }
645        }
646        
647        saxAdditionalInfosOnPageHit(page);
648        
649        XMLUtils.endElement(contentHandler, "hit");
650    }
651    
652    private void _saxPageContents(Page page) throws SAXException
653    {
654        if (page.getType() == PageType.CONTAINER)
655        {
656            for (Zone zone : page.getZones())
657            {
658                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
659                for (ZoneItem zoneItem : zoneItems)
660                {
661                    if (zoneItem.getType() == ZoneType.CONTENT)
662                    {
663                        // Content
664                        Content content = zoneItem.getContent();
665                        saxContent(content.getId(), "index", new Locale(page.getSitemapName()));
666                    }
667                }
668            }
669        }
670    }
671    
672    private void _saxLastModifiedDate(Page page) throws SAXException
673    {
674        ZonedDateTime lastModified = null;
675
676        if (page.getType() == PageType.CONTAINER)
677        {
678            for (Zone zone : page.getZones())
679            {
680                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
681                for (ZoneItem zoneItem : zoneItems)
682                {
683                    switch (zoneItem.getType())
684                    {
685                        case SERVICE:
686                            // A service has no last modification date
687                            break;
688                        case CONTENT:
689                            ZonedDateTime contentLastModified = zoneItem.getContent().getLastModified();
690
691                            if (contentLastModified != null && (lastModified == null || contentLastModified.isAfter(lastModified)))
692                            {
693                                // Keep the latest modification date
694                                lastModified = contentLastModified;
695                            }
696                            break;
697                        default:
698                            break;
699                    }
700                }
701            }
702        }
703        if (lastModified != null)
704        {
705            XMLUtils.createElement(contentHandler, "lastModified", DateUtils.zonedDateTimeToString(lastModified));
706        }
707    }
708    
709    private void _saxLastValidationDate(Page page) throws SAXException
710    {
711        ZonedDateTime lastValidated = null;
712        
713        if (page.getType() == PageType.CONTAINER)
714        {
715            for (Zone zone : page.getZones())
716            {
717                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
718                for (ZoneItem zoneItem : zoneItems)
719                {
720                    switch (zoneItem.getType())
721                    {
722                        case SERVICE:
723                            // A service has no last validation date
724                            break;
725                        case CONTENT:
726                            ZonedDateTime contentLastValidation = zoneItem.getContent().getLastValidationDate();
727                            
728                            if (contentLastValidation != null && (lastValidated == null || contentLastValidation.isAfter(lastValidated)))
729                            {
730                                // Keep the latest validation date
731                                lastValidated = contentLastValidation;
732                            }
733                            break;
734                        default:
735                            break;
736                    }
737                }
738            }
739        }
740        if (lastValidated != null)
741        {
742            XMLUtils.createElement(contentHandler, "lastValidation", DateUtils.zonedDateTimeToString(lastValidated));
743        }
744    }
745    
746    /**
747     * SAX additional information on page hit
748     * @param page the page
749     * @throws SAXException if something goes wrong when saxing the information
750     */
751    protected void saxAdditionalInfosOnPageHit(Page page) throws SAXException
752    {
753        // Nothing to do here.
754    }
755    
756    /**
757     * SAX a hit of type "resource".
758     * @param score The score of the page
759     * @param maxScore The maximum score of the search results
760     * @param resource The resource
761     * @throws SAXException If an error occurs while SAXing
762     */
763    protected void saxResourceHit(float score, float maxScore, Resource resource) throws SAXException
764    {
765        int percent = Math.min(Math.round(score * 100f / maxScore), 100);
766        
767        String filename = resource.getName();
768        
769        XMLUtils.startElement(contentHandler, "hit");
770        XMLUtils.createElement(contentHandler, "score", Float.toString(score));
771        XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent));
772        XMLUtils.createElement(contentHandler, "filename", filename);
773        XMLUtils.createElement(contentHandler, "title", StringUtils.substringBeforeLast(resource.getName(), "."));
774        XMLUtils.createElement(contentHandler, "id", resource.getId());
775        
776        String dcDescription = resource.getDCDescription();
777        String excerpt = _getResourceExcerpt(resource);
778        if (StringUtils.isNotBlank(dcDescription))
779        {
780            XMLUtils.createElement(contentHandler, "excerpt", dcDescription);
781        }
782        else if (StringUtils.isNotBlank(excerpt))
783        {
784            XMLUtils.createElement(contentHandler, "excerpt", excerpt + "...");
785        }
786        
787        XMLUtils.createElement(contentHandler, "type", "resource");
788        
789        Page page = _getResourcePage(resource);
790        if (page != null)
791        {
792            String pageUri = page.getSitemapName() + "/" + page.getPathInSitemap();
793            String encodedPath = FilenameUtils.encodePath(resource.getResourcePath());
794            
795            String uri = URIUtils.encodeURI(pageUri + "/_attachment" + encodedPath, Collections.singletonMap("download", "true"));
796            XMLUtils.createElement(contentHandler, "uri", uri);
797        }
798        
799        XMLUtils.createElement(contentHandler, "mime-types", resource.getMimeType());
800        _saxSize(resource.getLength());
801        _saxIcon(filename);
802        
803        Date lastModified = resource.getLastModified();
804        if (lastModified != null)
805        {
806            XMLUtils.createElement(contentHandler, "lastModified", DateUtils.dateToString(lastModified));
807        }
808        if (page != null)
809        {
810            Site site = page.getSite();
811            XMLUtils.createElement(contentHandler, "siteName", site.getName());
812            XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle());
813            XMLUtils.createElement(contentHandler, "siteUrl", site.getUrl());
814        }
815        
816        XMLUtils.endElement(contentHandler, "hit");
817    }
818    
819    private String _getResourceExcerpt(Resource resource)
820    {
821        try (InputStream is = resource.getInputStream())
822        {
823            TikaProvider tikaProvider = (TikaProvider) manager.lookup(TikaProvider.ROLE);
824            Tika tika = tikaProvider.getTika();
825            String value = tika.parseToString(is);
826            if (StringUtils.isNotBlank(value))
827            {
828                int summaryEndIndex = value.lastIndexOf(' ', 200);
829                if (summaryEndIndex == -1)
830                {
831                    summaryEndIndex = value.length();
832                }
833                return value.substring(0, summaryEndIndex) + (summaryEndIndex != value.length() ? "…" : "");
834            }
835        }
836        catch (Exception e)
837        {
838            getLogger().error("Unable to index resource at " + resource.getPath(), e);
839        }
840        return null;
841    }
842    
843    private Page _getResourcePage(Resource resource)
844    {
845        if (resource != null)
846        {
847            AmetysObject parent = resource.getParent();
848            while (parent != null)
849            {
850                if (parent instanceof Page)
851                {
852                    // We have gone up to the page
853                    return (Page) parent;
854                }
855                parent = parent.getParent();
856            }
857        }
858        
859        return null;
860    }
861    
862    /**
863     * SAX elements for pagination
864     * @param totalHits The total number of result
865     * @param start The start index of search
866     * @param offset The max number of results per page
867     * @throws SAXException SAXException If an error occurs while SAXing
868     */
869    protected void saxPagination(long totalHits, int start, int offset) throws SAXException
870    {
871        int nbPages = (int) Math.ceil((double) totalHits / (double) offset);
872        
873        AttributesImpl atts = new AttributesImpl();
874        atts.addCDATAAttribute("total", String.valueOf(nbPages)); // Number of pages
875        atts.addCDATAAttribute("start", String.valueOf(start)); // Index of the first hit
876        atts.addCDATAAttribute("end", start + offset > totalHits ? String.valueOf(totalHits) : String.valueOf(start + offset)); // Index of the last hit
877        
878        XMLUtils.startElement(contentHandler, "pagination", atts);
879        
880        for (int i = 0; i < nbPages; i++)
881        {
882            AttributesImpl attr = new AttributesImpl();
883            attr.addAttribute("", "index", "index", "CDATA", String.valueOf(i + 1));
884            attr.addAttribute("", "start", "start", "CDATA", String.valueOf(i * offset));
885            XMLUtils.createElement(contentHandler, "page", attr);
886        }
887        
888        XMLUtils.endElement(contentHandler, "pagination");
889    }
890    
891    /**
892     * Get the page index
893     * @param request The request
894     * @return The page index
895     */
896    protected int getPageIndex(Request request)
897    {
898        Enumeration paramNames = request.getParameterNames();
899        while (paramNames.hasMoreElements())
900        {
901            String param = (String) paramNames.nextElement();
902            if (param.startsWith("page-"))
903            {
904                return Integer.parseInt(param.substring("page-".length()));
905            }
906        }
907        return 1;
908    }
909    
910    private void _saxIcon(String filename) throws SAXException
911    {
912        int index = filename.lastIndexOf('.');
913        String extension = filename.substring(index + 1);
914
915        XMLUtils.createElement(contentHandler, "icon", "plugins/explorer/icon-medium/" + extension + ".png");
916    }
917    
918    private void _saxSize(long size) throws SAXException
919    {
920        org.ametys.core.util.StringUtils.toReadableDataSize(size).toSAX(contentHandler, "size");
921    }
922    
923    /**
924     * Generate the service identifiers: service group ID, ZoneItem ID, ...
925     * @throws SAXException if an error occurs SAXing data.
926     * @throws IOException if an error occurs SAXing data.
927     * @throws ProcessingException if a processing error occurs.
928     */
929    protected void saxServiceIdentifiers() throws SAXException, IOException, ProcessingException
930    {
931        Request request = ObjectModelHelper.getRequest(objectModel);
932        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
933        String serviceGroupId = parameters.getParameter("service-group-id", "");
934        
935        // The service group ID.
936        if (StringUtils.isNotEmpty(serviceGroupId))
937        {
938            XMLUtils.createElement(contentHandler, "group-id", serviceGroupId);
939        }
940        
941        // Generate the ZoneItem ID if it exists.
942        if (zoneItem != null)
943        {
944            AttributesImpl atts = new AttributesImpl();
945            atts.addCDATAAttribute("id", zoneItem.getId());
946            XMLUtils.createElement(contentHandler, "zone-item", atts);
947        }
948    }
949    
950    /**
951     * Generate any additional information.
952     * @throws SAXException if an error occurs SAXing data.
953     * @throws IOException if an error occurs SAXing data.
954     * @throws ProcessingException if a processing error occurs.
955     */
956    protected void saxAdditionalInfos() throws SAXException, IOException, ProcessingException
957    {
958        // Nothing to do here.
959    }
960    
961    /**
962     * Get the search mode.
963     * @return the search mode as a string.
964     */
965    protected String getSearchMode()
966    {
967        return parameters.getParameter("search-mode", "criteria-and-results");
968    }
969    
970    /**
971     * Check if the input is valid.
972     * @return true if the input is valid, false otherwise.
973     */
974    protected boolean isInputValid()
975    {
976        boolean valid = true;
977        
978        Request request = ObjectModelHelper.getRequest(objectModel);
979        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
980        String serviceGroupId = parameters.getParameter("service-group-id", "");
981        String requestParamGroupId = StringUtils.defaultString(request.getParameter("submit-form"));
982        String requestParamZoneItemId = StringUtils.defaultString(request.getParameter("zone-item-id"));
983        
984        // If the generator is not used as part of a service (zoneItem is null), consider the input valid.
985        if (zoneItem != null)
986        {
987            // If the generator is used as part of a service and both "group ID"
988            // and "zone item ID" are missing from the input, consider the input valid.
989            if (StringUtils.isNotEmpty(requestParamGroupId) || StringUtils.isNotEmpty(requestParamZoneItemId))
990            {
991                if (StringUtils.isEmpty(serviceGroupId))
992                {
993                    // No specified group ID: check the provided ZoneItem ID.
994                    valid = requestParamZoneItemId.equals(zoneItem.getId());
995                }
996                else
997                {
998                    // Check the group ID against the one sent by the form.
999                    valid = requestParamGroupId.equals(serviceGroupId);
1000                }
1001            }
1002        }
1003        return valid;
1004    }
1005    
1006    /**
1007     * SAX the view of a content if exists
1008     * @param contentId the id of the content 
1009     * @param viewName The name of view to sax
1010     * @param defaultLocale The locale to use to sax localized values such as multilingual content or multilingual string. Only use if initial content's language is not null.
1011     * @throws SAXException if an exception occurs while saxing
1012     */
1013    protected void saxContent(String contentId, String viewName, Locale defaultLocale) throws SAXException
1014    {   
1015        try
1016        {
1017            Content content = _resolver.resolveById(contentId);
1018            String[] contentTypes = content.getTypes();
1019            
1020            // content-name
1021            XMLUtils.createElement(contentHandler, "content-name", content.getName());
1022            
1023            // content-types
1024            XMLUtils.startElement(contentHandler, "content-types");
1025            Arrays.asList(contentTypes).forEach(LambdaUtils.wrapConsumer(cType -> XMLUtils.createElement(contentHandler, "content-type", cType)));
1026            XMLUtils.endElement(contentHandler, "content-types");
1027            
1028            View view = _contentTypesHelper.getView(viewName, contentTypes, content.getMixinTypes());
1029            if (view != null)
1030            {
1031                Set<String> richTextAttributesPaths = ViewHelper.getModelItemsFromView(view)
1032                                                        .parallelStream()
1033                                                        .filter(Objects::nonNull)
1034                                                        .filter(item -> ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(item.getType().getId()))
1035                                                        .map(ModelItem::getPath)
1036                                                        .collect(Collectors.toSet());
1037                
1038                for (String richTextAttributePath : richTextAttributesPaths)
1039                {
1040                    Object value = content.getValue(richTextAttributePath, true);
1041                    if (value != null && value instanceof RichText)
1042                    {
1043                        saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) value);
1044                    }
1045                    else if (value instanceof RichText[])
1046                    {
1047                        for (Object v : (RichText[]) value)
1048                        {
1049                            saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) v);
1050                        }
1051                    }
1052                }
1053               
1054                AttributesImpl attrs = new AttributesImpl();
1055                attrs.addCDATAAttribute("id", content.getId());
1056                attrs.addCDATAAttribute("name", content.getName());
1057                if (content.getLanguage() != null)
1058                {
1059                    attrs.addCDATAAttribute("language", content.getLanguage());
1060                }
1061                XMLUtils.startElement(contentHandler, "content", attrs);
1062                
1063                content.dataToSAX(contentHandler, view, DataContext.newInstance().withLocale(defaultLocale));
1064
1065                XMLUtils.endElement(contentHandler, "content");
1066            }
1067        }
1068        catch (AmetysRepositoryException e)
1069        {
1070            getLogger().error("Cannot sax information about the content " + contentId, e);
1071        }
1072    }
1073    
1074    /**
1075     * SAX excerpt for rich text
1076     * @param attributePath The path of attribute
1077     * @param contentId The content id
1078     * @param richText The rich text
1079     */
1080    protected void saxRichTextExcerpt(String attributePath, String contentId, RichText richText)
1081    {
1082        SAXParser saxParser = null;
1083        try (InputStream is = richText.getInputStream())
1084        {
1085            RichTextHandler txtHandler = new RichTextHandler(200);
1086            saxParser = (SAXParser) manager.lookup(SAXParser.ROLE);
1087            saxParser.parse(new InputSource(is), txtHandler);
1088            String textValue = txtHandler.getValue();
1089            if (textValue != null)
1090            {
1091                XMLUtils.createElement(contentHandler, "excerpt", textValue);
1092            }
1093        }
1094        catch (Exception e)
1095        {
1096            getLogger().error("Cannot convert a richtextvalue at path '" + attributePath + "' of content '" + contentId + "'", e);
1097        }
1098        finally
1099        {
1100            manager.release(saxParser);
1101        }
1102    }
1103
1104    /**
1105     * <code>true</code> to check rights during search
1106     * @return <code>true</code> to check rights during search
1107     */
1108    protected boolean _checkRights()
1109    {
1110        return parameters.getParameterAsBoolean("check-rights", true);
1111    }
1112    
1113    /**
1114     * Interface representing a facet field
1115     *
1116     */
1117    protected interface FacetField
1118    {
1119        /**
1120         * Get the search field for this facet 
1121         * @return the search field
1122         */
1123        public SearchField getSearchField();
1124        
1125        /**
1126         * Get the label of the facet
1127         * @return the label
1128         */
1129        public I18nizableText getLabel();
1130        
1131        /**
1132         * Get the label for a facet value
1133         * @param value the value
1134         * @param currentLocale the current locale
1135         * @return the label for this value
1136         */
1137        public I18nizableText getFacetLabel(String value, Locale currentLocale);
1138    }
1139    
1140    /**
1141     * Facet field for content types
1142     *
1143     */
1144    protected class ContentTypeFacetField implements FacetField
1145    {
1146        private SearchField _field;
1147        
1148        /**
1149         * Constructor
1150         * @param field The search field
1151         */
1152        public ContentTypeFacetField(SearchField field)
1153        {
1154            _field = field;
1155        }
1156        
1157        @Override
1158        public SearchField getSearchField()
1159        {
1160            return _field;
1161        }
1162        
1163        @Override
1164        public I18nizableText getLabel()
1165        {
1166            return new I18nizableText("plugin.web", "PLUGINS_WEB_SERVICE_FRONT_SEARCH_CONTENT_TYPE_FACET_LABEL");
1167        }
1168        
1169        @Override
1170        public I18nizableText getFacetLabel(String value, Locale currentLocale)
1171        {
1172            ContentType cType = _cTypeExtPt.getExtension(value);
1173            if (cType != null)
1174            {
1175                return cType.getLabel();
1176            }
1177            
1178            return new I18nizableText(value);
1179        }
1180    }
1181    
1182    /**
1183     * Facet field for an attribute
1184     *
1185     */
1186    protected class AttributeFacetField implements FacetField
1187    {
1188        private SearchField _field;
1189        private ModelItem _modelItem;
1190        private Logger _logger;
1191        
1192        /**
1193         * Constructor
1194         * @param field The search field
1195         * @param modelItem The model item
1196         * @param logger The logger
1197         */
1198        public AttributeFacetField(SearchField field, ModelItem modelItem, Logger logger)
1199        {
1200            _field = field;
1201            _modelItem = modelItem;
1202            _logger = logger;
1203        }
1204        
1205        @Override
1206        public SearchField getSearchField()
1207        {
1208            return _field;
1209        }
1210        
1211        @Override
1212        public I18nizableText getLabel()
1213        {
1214            return _modelItem.getLabel();
1215        }
1216        
1217        @Override
1218        public I18nizableText getFacetLabel(String value, Locale currentLocale)
1219        {
1220            try
1221            {
1222                if (_modelItem != null && _modelItem instanceof ElementDefinition)
1223                {
1224                    ElementDefinition elementDefinition = (ElementDefinition) _modelItem;
1225                    if (elementDefinition.getEnumerator() != null)
1226                    {
1227                        return elementDefinition.getEnumerator().getEntry(value);
1228                    }
1229                    else if (elementDefinition.getType().getId().equals(ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID))
1230                    {
1231                        Content content = _resolver.resolveById(value);
1232                        return new I18nizableText(content.getTitle(currentLocale));
1233                    }
1234                }
1235            }
1236            catch (Exception e)
1237            {
1238                _logger.error("Failed to get label of facet value '" + value + "'. Raw value itself will be used.", e);
1239            }
1240            
1241            return new I18nizableText(value);
1242        }
1243    }
1244}