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