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