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