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.file.TikaProvider;
073import org.ametys.core.util.DateUtils;
074import org.ametys.core.util.FilenameUtils;
075import org.ametys.core.util.LambdaUtils;
076import org.ametys.core.util.URIUtils;
077import org.ametys.plugins.explorer.resources.Resource;
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     * SAX a hit of type page.
606     * @param score The score of the page
607     * @param maxScore The maximum score of the search results
608     * @param page The page
609     * @throws SAXException If an error occurs while SAXing
610     */
611    protected void saxPageHit(float score, float maxScore, Page page) throws SAXException
612    {
613        int percent = Math.min(Math.round(score * 100f / maxScore), 100);
614        
615        XMLUtils.startElement(contentHandler, "hit");
616        XMLUtils.createElement(contentHandler, "score", Float.toString(score));
617        XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent));
618        XMLUtils.createElement(contentHandler, "title", page.getTitle());
619        
620        _saxPageContents(page);
621        
622        XMLUtils.createElement(contentHandler, "type", "page");
623        XMLUtils.createElement(contentHandler, "uri", page.getSitemap().getName() + "/" + page.getPathInSitemap());
624        
625        _saxLastModifiedDate(page);
626        _saxLastValidationDate(page);
627        
628        String siteName = page.getSiteName();
629        if (siteName != null)
630        {
631            Site site = _siteManager.getSite(siteName);
632            XMLUtils.createElement(contentHandler, "siteName", siteName);
633            XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle());
634            String url = site.getUrl();
635            if (url != null)
636            {
637                XMLUtils.createElement(contentHandler, "siteUrl", url);
638            }
639        }
640        
641        saxAdditionalInfosOnPageHit(page);
642        
643        XMLUtils.endElement(contentHandler, "hit");
644    }
645    
646    private void _saxPageContents(Page page) throws SAXException
647    {
648        if (page.getType() == PageType.CONTAINER)
649        {
650            for (Zone zone : page.getZones())
651            {
652                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
653                for (ZoneItem zoneItem : zoneItems)
654                {
655                    if (zoneItem.getType() == ZoneType.CONTENT)
656                    {
657                        // Content
658                        Content content = zoneItem.getContent();
659                        saxContent(content.getId(), "index", new Locale(page.getSitemapName()));
660                    }
661                }
662            }
663        }
664    }
665    
666    private void _saxLastModifiedDate(Page page) throws SAXException
667    {
668        ZonedDateTime lastModified = null;
669
670        if (page.getType() == PageType.CONTAINER)
671        {
672            for (Zone zone : page.getZones())
673            {
674                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
675                for (ZoneItem zoneItem : zoneItems)
676                {
677                    switch (zoneItem.getType())
678                    {
679                        case SERVICE:
680                            // A service has no last modification date
681                            break;
682                        case CONTENT:
683                            ZonedDateTime contentLastModified = zoneItem.getContent().getLastModified();
684
685                            if (contentLastModified != null && (lastModified == null || contentLastModified.isAfter(lastModified)))
686                            {
687                                // Keep the latest modification date
688                                lastModified = contentLastModified;
689                            }
690                            break;
691                        default:
692                            break;
693                    }
694                }
695            }
696        }
697        if (lastModified != null)
698        {
699            XMLUtils.createElement(contentHandler, "lastModified", DateUtils.zonedDateTimeToString(lastModified));
700        }
701    }
702    
703    private void _saxLastValidationDate(Page page) throws SAXException
704    {
705        ZonedDateTime lastValidated = null;
706        
707        if (page.getType() == PageType.CONTAINER)
708        {
709            for (Zone zone : page.getZones())
710            {
711                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
712                for (ZoneItem zoneItem : zoneItems)
713                {
714                    switch (zoneItem.getType())
715                    {
716                        case SERVICE:
717                            // A service has no last validation date
718                            break;
719                        case CONTENT:
720                            ZonedDateTime contentLastValidation = zoneItem.getContent().getLastValidationDate();
721                            
722                            if (contentLastValidation != null && (lastValidated == null || contentLastValidation.isAfter(lastValidated)))
723                            {
724                                // Keep the latest validation date
725                                lastValidated = contentLastValidation;
726                            }
727                            break;
728                        default:
729                            break;
730                    }
731                }
732            }
733        }
734        if (lastValidated != null)
735        {
736            XMLUtils.createElement(contentHandler, "lastValidation", DateUtils.zonedDateTimeToString(lastValidated));
737        }
738    }
739    
740    /**
741     * SAX additional information on page hit
742     * @param page the page
743     * @throws SAXException if something goes wrong when saxing the information
744     */
745    protected void saxAdditionalInfosOnPageHit(Page page) throws SAXException
746    {
747        // Nothing to do here.
748    }
749    
750    /**
751     * SAX a hit of type "resource".
752     * @param score The score of the page
753     * @param maxScore The maximum score of the search results
754     * @param resource The resource
755     * @throws SAXException If an error occurs while SAXing
756     */
757    protected void saxResourceHit(float score, float maxScore, Resource resource) throws SAXException
758    {
759        int percent = Math.min(Math.round(score * 100f / maxScore), 100);
760        
761        String filename = resource.getName();
762        
763        XMLUtils.startElement(contentHandler, "hit");
764        XMLUtils.createElement(contentHandler, "score", Float.toString(score));
765        XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent));
766        XMLUtils.createElement(contentHandler, "filename", filename);
767        XMLUtils.createElement(contentHandler, "title", StringUtils.substringBeforeLast(resource.getName(), "."));
768        XMLUtils.createElement(contentHandler, "id", resource.getId());
769        
770        String dcDescription = resource.getDCDescription();
771        String excerpt = _getResourceExcerpt(resource);
772        if (StringUtils.isNotBlank(dcDescription))
773        {
774            XMLUtils.createElement(contentHandler, "excerpt", dcDescription);
775        }
776        else if (StringUtils.isNotBlank(excerpt))
777        {
778            XMLUtils.createElement(contentHandler, "excerpt", excerpt + "...");
779        }
780        
781        XMLUtils.createElement(contentHandler, "type", "resource");
782        
783        Page page = _getResourcePage(resource);
784        if (page != null)
785        {
786            String pageUri = page.getSitemapName() + "/" + page.getPathInSitemap();
787            String encodedPath = FilenameUtils.encodePath(resource.getResourcePath());
788            
789            String uri = URIUtils.encodeURI(pageUri + "/_attachment" + encodedPath, Collections.singletonMap("download", "true"));
790            XMLUtils.createElement(contentHandler, "uri", uri);
791        }
792        
793        XMLUtils.createElement(contentHandler, "mime-types", resource.getMimeType());
794        _saxSize(resource.getLength());
795        _saxIcon(filename);
796        
797        Date lastModified = resource.getLastModified();
798        if (lastModified != null)
799        {
800            XMLUtils.createElement(contentHandler, "lastModified", DateUtils.dateToString(lastModified));
801        }
802        if (page != null)
803        {
804            Site site = page.getSite();
805            XMLUtils.createElement(contentHandler, "siteName", site.getName());
806            XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle());
807            XMLUtils.createElement(contentHandler, "siteUrl", site.getUrl());
808        }
809        
810        XMLUtils.endElement(contentHandler, "hit");
811    }
812    
813    private String _getResourceExcerpt(Resource resource)
814    {
815        try (InputStream is = resource.getInputStream())
816        {
817            TikaProvider tikaProvider = (TikaProvider) manager.lookup(TikaProvider.ROLE);
818            Tika tika = tikaProvider.getTika();
819            String value = tika.parseToString(is);
820            if (StringUtils.isNotBlank(value))
821            {
822                int summaryEndIndex = value.lastIndexOf(' ', 200);
823                if (summaryEndIndex == -1)
824                {
825                    summaryEndIndex = value.length();
826                }
827                return value.substring(0, summaryEndIndex) + (summaryEndIndex != value.length() ? "…" : "");
828            }
829        }
830        catch (Exception e)
831        {
832            getLogger().error("Unable to index resource at " + resource.getPath(), e);
833        }
834        return null;
835    }
836    
837    private Page _getResourcePage(Resource resource)
838    {
839        if (resource != null)
840        {
841            AmetysObject parent = resource.getParent();
842            while (parent != null)
843            {
844                if (parent instanceof Page)
845                {
846                    // We have gone up to the page
847                    return (Page) parent;
848                }
849                parent = parent.getParent();
850            }
851        }
852        
853        return null;
854    }
855    
856    /**
857     * SAX elements for pagination
858     * @param totalHits The total number of result
859     * @param start The start index of search
860     * @param offset The max number of results per page
861     * @throws SAXException SAXException If an error occurs while SAXing
862     */
863    protected void saxPagination(long totalHits, int start, int offset) throws SAXException
864    {
865        int nbPages = (int) Math.ceil((double) totalHits / (double) offset);
866        
867        AttributesImpl atts = new AttributesImpl();
868        atts.addCDATAAttribute("total", String.valueOf(nbPages)); // Number of pages
869        atts.addCDATAAttribute("start", String.valueOf(start)); // Index of the first hit
870        atts.addCDATAAttribute("end", start + offset > totalHits ? String.valueOf(totalHits) : String.valueOf(start + offset)); // Index of the last hit
871        
872        XMLUtils.startElement(contentHandler, "pagination", atts);
873        
874        for (int i = 0; i < nbPages; i++)
875        {
876            AttributesImpl attr = new AttributesImpl();
877            attr.addAttribute("", "index", "index", "CDATA", String.valueOf(i + 1));
878            attr.addAttribute("", "start", "start", "CDATA", String.valueOf(i * offset));
879            XMLUtils.createElement(contentHandler, "page", attr);
880        }
881        
882        XMLUtils.endElement(contentHandler, "pagination");
883    }
884    
885    /**
886     * Get the page index
887     * @param request The request
888     * @return The page index
889     */
890    protected int getPageIndex(Request request)
891    {
892        Enumeration paramNames = request.getParameterNames();
893        while (paramNames.hasMoreElements())
894        {
895            String param = (String) paramNames.nextElement();
896            if (param.startsWith("page-"))
897            {
898                return Integer.parseInt(param.substring("page-".length()));
899            }
900        }
901        return 1;
902    }
903    
904    private void _saxIcon(String filename) throws SAXException
905    {
906        int index = filename.lastIndexOf('.');
907        String extension = filename.substring(index + 1);
908
909        XMLUtils.createElement(contentHandler, "icon", "plugins/explorer/icon-medium/" + extension + ".png");
910    }
911    
912    private void _saxSize(long size) throws SAXException
913    {
914        org.ametys.core.util.StringUtils.toReadableDataSize(size).toSAX(contentHandler, "size");
915    }
916    
917    /**
918     * Generate the service identifiers: service group ID, ZoneItem ID, ...
919     * @throws SAXException if an error occurs SAXing data.
920     * @throws IOException if an error occurs SAXing data.
921     * @throws ProcessingException if a processing error occurs.
922     */
923    protected void saxServiceIdentifiers() throws SAXException, IOException, ProcessingException
924    {
925        Request request = ObjectModelHelper.getRequest(objectModel);
926        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
927        String serviceGroupId = parameters.getParameter("service-group-id", "");
928        
929        // The service group ID.
930        if (StringUtils.isNotEmpty(serviceGroupId))
931        {
932            XMLUtils.createElement(contentHandler, "group-id", serviceGroupId);
933        }
934        
935        // Generate the ZoneItem ID if it exists.
936        if (zoneItem != null)
937        {
938            AttributesImpl atts = new AttributesImpl();
939            atts.addCDATAAttribute("id", zoneItem.getId());
940            XMLUtils.createElement(contentHandler, "zone-item", atts);
941        }
942    }
943    
944    /**
945     * Generate any additional information.
946     * @throws SAXException if an error occurs SAXing data.
947     * @throws IOException if an error occurs SAXing data.
948     * @throws ProcessingException if a processing error occurs.
949     */
950    protected void saxAdditionalInfos() throws SAXException, IOException, ProcessingException
951    {
952        // Nothing to do here.
953    }
954    
955    /**
956     * Get the search mode.
957     * @return the search mode as a string.
958     */
959    protected String getSearchMode()
960    {
961        return parameters.getParameter("search-mode", "criteria-and-results");
962    }
963    
964    /**
965     * Check if the input is valid.
966     * @return true if the input is valid, false otherwise.
967     */
968    protected boolean isInputValid()
969    {
970        boolean valid = true;
971        
972        Request request = ObjectModelHelper.getRequest(objectModel);
973        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
974        String serviceGroupId = parameters.getParameter("service-group-id", "");
975        String requestParamGroupId = StringUtils.defaultString(request.getParameter("submit-form"));
976        String requestParamZoneItemId = StringUtils.defaultString(request.getParameter("zone-item-id"));
977        
978        // If the generator is not used as part of a service (zoneItem is null), consider the input valid.
979        if (zoneItem != null)
980        {
981            // If the generator is used as part of a service and both "group ID"
982            // and "zone item ID" are missing from the input, consider the input valid.
983            if (StringUtils.isNotEmpty(requestParamGroupId) || StringUtils.isNotEmpty(requestParamZoneItemId))
984            {
985                if (StringUtils.isEmpty(serviceGroupId))
986                {
987                    // No specified group ID: check the provided ZoneItem ID.
988                    valid = requestParamZoneItemId.equals(zoneItem.getId());
989                }
990                else
991                {
992                    // Check the group ID against the one sent by the form.
993                    valid = requestParamGroupId.equals(serviceGroupId);
994                }
995            }
996        }
997        return valid;
998    }
999    
1000    /**
1001     * SAX the view of a content if exists
1002     * @param contentId the id of the content 
1003     * @param viewName The name of view to sax
1004     * @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.
1005     * @throws SAXException if an exception occurs while saxing
1006     */
1007    protected void saxContent(String contentId, String viewName, Locale defaultLocale) throws SAXException
1008    {   
1009        try
1010        {
1011            Content content = _resolver.resolveById(contentId);
1012            String[] contentTypes = content.getTypes();
1013            
1014            // content-name
1015            XMLUtils.createElement(contentHandler, "content-name", content.getName());
1016            
1017            // content-types
1018            XMLUtils.startElement(contentHandler, "content-types");
1019            Arrays.asList(contentTypes).forEach(LambdaUtils.wrapConsumer(cType -> XMLUtils.createElement(contentHandler, "content-type", cType)));
1020            XMLUtils.endElement(contentHandler, "content-types");
1021            
1022            View view = _contentTypesHelper.getView(viewName, contentTypes, content.getMixinTypes());
1023            if (view != null)
1024            {
1025                Set<String> richTextAttributesPaths = ViewHelper.getModelItemsFromView(view)
1026                                                        .parallelStream()
1027                                                        .filter(Objects::nonNull)
1028                                                        .filter(item -> ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(item.getType().getId()))
1029                                                        .map(ModelItem::getPath)
1030                                                        .collect(Collectors.toSet());
1031                
1032                for (String richTextAttributePath : richTextAttributesPaths)
1033                {
1034                    Object value = content.getValue(richTextAttributePath, true);
1035                    if (value != null && value instanceof RichText)
1036                    {
1037                        saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) value);
1038                    }
1039                    else if (value instanceof RichText[])
1040                    {
1041                        for (Object v : (RichText[]) value)
1042                        {
1043                            saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) v);
1044                        }
1045                    }
1046                }
1047               
1048                AttributesImpl attrs = new AttributesImpl();
1049                attrs.addCDATAAttribute("id", content.getId());
1050                attrs.addCDATAAttribute("name", content.getName());
1051                if (content.getLanguage() != null)
1052                {
1053                    attrs.addCDATAAttribute("language", content.getLanguage());
1054                }
1055                XMLUtils.startElement(contentHandler, "content", attrs);
1056                
1057                content.dataToSAX(contentHandler, view, DataContext.newInstance().withLocale(defaultLocale));
1058
1059                XMLUtils.endElement(contentHandler, "content");
1060            }
1061        }
1062        catch (AmetysRepositoryException e)
1063        {
1064            getLogger().error("Cannot sax information about the content " + contentId, e);
1065        }
1066    }
1067    
1068    /**
1069     * SAX excerpt for rich text
1070     * @param attributePath The path of attribute
1071     * @param contentId The content id
1072     * @param richText The rich text
1073     */
1074    protected void saxRichTextExcerpt(String attributePath, String contentId, RichText richText)
1075    {
1076        SAXParser saxParser = null;
1077        try (InputStream is = richText.getInputStream())
1078        {
1079            RichTextHandler txtHandler = new RichTextHandler(200);
1080            saxParser = (SAXParser) manager.lookup(SAXParser.ROLE);
1081            saxParser.parse(new InputSource(is), txtHandler);
1082            String textValue = txtHandler.getValue();
1083            if (textValue != null)
1084            {
1085                XMLUtils.createElement(contentHandler, "excerpt", textValue);
1086            }
1087        }
1088        catch (Exception e)
1089        {
1090            getLogger().error("Cannot convert a richtextvalue at path '" + attributePath + "' of content '" + contentId + "'", e);
1091        }
1092        finally
1093        {
1094            manager.release(saxParser);
1095        }
1096    }
1097
1098    /**
1099     * <code>true</code> to check rights during search
1100     * @return <code>true</code> to check rights during search
1101     */
1102    protected boolean _checkRights()
1103    {
1104        return parameters.getParameterAsBoolean("check-rights", true);
1105    }
1106    
1107    /**
1108     * Interface representing a facet field
1109     *
1110     */
1111    protected interface FacetField
1112    {
1113        /**
1114         * Get the search field for this facet 
1115         * @return the search field
1116         */
1117        public SearchField getSearchField();
1118        
1119        /**
1120         * Get the label of the facet
1121         * @return the label
1122         */
1123        public I18nizableText getLabel();
1124        
1125        /**
1126         * Get the label for a facet value
1127         * @param value the value
1128         * @param currentLocale the current locale
1129         * @return the label for this value
1130         */
1131        public I18nizableText getFacetLabel(String value, Locale currentLocale);
1132    }
1133    
1134    /**
1135     * Facet field for content types
1136     *
1137     */
1138    protected class ContentTypeFacetField implements FacetField
1139    {
1140        private SearchField _field;
1141        
1142        /**
1143         * Constructor
1144         * @param field The search field
1145         */
1146        public ContentTypeFacetField(SearchField field)
1147        {
1148            _field = field;
1149        }
1150        
1151        @Override
1152        public SearchField getSearchField()
1153        {
1154            return _field;
1155        }
1156        
1157        @Override
1158        public I18nizableText getLabel()
1159        {
1160            return new I18nizableText("plugin.web", "PLUGINS_WEB_SERVICE_FRONT_SEARCH_CONTENT_TYPE_FACET_LABEL");
1161        }
1162        
1163        @Override
1164        public I18nizableText getFacetLabel(String value, Locale currentLocale)
1165        {
1166            ContentType cType = _cTypeExtPt.getExtension(value);
1167            if (cType != null)
1168            {
1169                return cType.getLabel();
1170            }
1171            
1172            return new I18nizableText(value);
1173        }
1174    }
1175    
1176    /**
1177     * Facet field for an attribute
1178     *
1179     */
1180    protected class AttributeFacetField implements FacetField
1181    {
1182        private SearchField _field;
1183        private ModelItem _modelItem;
1184        private Logger _logger;
1185        
1186        /**
1187         * Constructor
1188         * @param field The search field
1189         * @param modelItem The model item
1190         * @param logger The logger
1191         */
1192        public AttributeFacetField(SearchField field, ModelItem modelItem, Logger logger)
1193        {
1194            _field = field;
1195            _modelItem = modelItem;
1196            _logger = logger;
1197        }
1198        
1199        @Override
1200        public SearchField getSearchField()
1201        {
1202            return _field;
1203        }
1204        
1205        @Override
1206        public I18nizableText getLabel()
1207        {
1208            return _modelItem.getLabel();
1209        }
1210        
1211        @Override
1212        public I18nizableText getFacetLabel(String value, Locale currentLocale)
1213        {
1214            try
1215            {
1216                if (_modelItem != null && _modelItem instanceof ElementDefinition)
1217                {
1218                    ElementDefinition elementDefinition = (ElementDefinition) _modelItem;
1219                    if (elementDefinition.getEnumerator() != null)
1220                    {
1221                        return elementDefinition.getEnumerator().getEntry(value);
1222                    }
1223                    else if (elementDefinition.getType().getId().equals(ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID))
1224                    {
1225                        Content content = _resolver.resolveById(value);
1226                        return new I18nizableText(content.getTitle(currentLocale));
1227                    }
1228                }
1229            }
1230            catch (Exception e)
1231            {
1232                _logger.error("Failed to get label of facet value '" + value + "'. Raw value itself will be used.", e);
1233            }
1234            
1235            return new I18nizableText(value);
1236        }
1237    }
1238}