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