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.cms.content.indexing.solr;
017
018import java.io.InputStream;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Date;
022import java.util.List;
023import java.util.Optional;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.LocaleUtils;
030import org.apache.commons.lang3.StringUtils;
031import org.apache.solr.common.SolrInputDocument;
032import org.apache.tika.Tika;
033import org.apache.tika.exception.ZeroByteFileException;
034
035import org.ametys.cms.data.type.indexing.IndexableElementTypeHelper;
036import org.ametys.cms.indexing.solr.AdditionalDataIndexer;
037import org.ametys.cms.indexing.solr.AdditionalDataIndexerExtensionPoint;
038import org.ametys.cms.languages.LanguagesManager;
039import org.ametys.cms.model.CMSDataContext;
040import org.ametys.cms.search.systemprop.ContentTypeSystemProperty;
041import org.ametys.cms.search.systemprop.FirstValidationSystemProperty;
042import org.ametys.cms.search.systemprop.LastMajorValidationSystemProperty;
043import org.ametys.cms.search.systemprop.LastModifiedSystemProperty;
044import org.ametys.cms.search.systemprop.LastValidationSystemProperty;
045import org.ametys.core.file.TikaProvider;
046import org.ametys.core.user.UserIdentity;
047import org.ametys.core.util.DateUtils;
048import org.ametys.plugins.explorer.resources.Resource;
049import org.ametys.plugins.explorer.resources.ResourceCollection;
050import org.ametys.plugins.explorer.resources.ResourceHelper;
051import org.ametys.plugins.repository.AmetysObject;
052import org.ametys.plugins.repository.TraversableAmetysObject;
053import org.ametys.plugins.repository.dublincore.DublinCoreAwareAmetysObject;
054import org.ametys.runtime.plugin.component.AbstractLogEnabled;
055
056/**
057 * Solr resource indexer.<p>
058 * Populate a Solr input document with the following fields:
059 * <dl>
060 *  <dt>id
061 *  <dd>resource id
062 *  <dt>type
063 *  <dd>with <code>"document"</code> value
064 *  <dt>full
065 *  <dd>resource content
066 * </dl>
067 */
068public class SolrResourceIndexer extends AbstractLogEnabled implements Component, Serviceable, SolrFieldNames
069{
070    /** The avalon role. */
071    public static final String ROLE = SolrResourceIndexer.class.getName();
072    
073    /** The Tika instance. */
074    protected Tika _tika;
075    /** The language manager. */
076    protected LanguagesManager _langManager;
077    /** The solr indexer */
078    protected SolrIndexer _solrIndexer;
079    /** The additional property indexer extension point. */
080    protected AdditionalDataIndexerExtensionPoint _additionalDataIndexerEP;
081    
082    @Override
083    public void service(ServiceManager manager) throws ServiceException
084    {
085        TikaProvider tikaProvider = (TikaProvider) manager.lookup(TikaProvider.ROLE);
086        _tika = tikaProvider.getTika();
087        _langManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
088        _solrIndexer = (SolrIndexer) manager.lookup(SolrIndexer.ROLE);
089        _additionalDataIndexerEP = (AdditionalDataIndexerExtensionPoint) manager.lookup(AdditionalDataIndexerExtensionPoint.ROLE);
090    }
091    
092    /**
093     * Index a resource.
094     * @param resource The resource to index.
095     * @param document The Solr document to index into.
096     * @param documentType The document type of the resource
097     * @return Additional documents for the resource indexation
098     * @throws Exception if an error occurs.
099     */
100    public List<SolrInputDocument> indexResource(Resource resource, SolrInputDocument document, String documentType) throws Exception
101    {
102        return indexResource(resource, document, documentType, null, null);
103    }
104    
105    /**
106     * Index a resource.
107     * @param resource The resource to index.
108     * @param document The Solr document to index into.
109     * @param documentType The document type of the resource
110     * @param language The query language.
111     * @return Additional documents for the resource indexation
112     * @throws Exception if an error occurs.
113     */
114    public List<SolrInputDocument> indexResource(Resource resource, SolrInputDocument document, String documentType, String language) throws Exception
115    {
116        return indexResource(resource, document, documentType, language, null);
117    }
118    
119    /**
120     * Index a resource.
121     * @param resource The resource to index.
122     * @param document The Solr document to index into.
123     * @param documentType The document type of the resource
124     * @param resourceRoot The resource root, can be null. When null, it will have to be computed.
125     * @return Additional documents for the resource indexation
126     * @throws Exception if an error occurs.
127     */
128    public List<SolrInputDocument> indexResource(Resource resource, SolrInputDocument document, String documentType, TraversableAmetysObject resourceRoot) throws Exception
129    {
130        return indexResource(resource, document, documentType, null, resourceRoot);
131    }
132    
133    /**
134     * Index a resource.
135     * @param resource The resource to index.
136     * @param document The Solr document to index into.
137     * @param documentType The document type of the resource
138     * @param language The language, can be null.
139     * @param resourceRoot The resource root, can be null. When null, it will have to be computed.
140     * @return Additional documents for the resource indexation
141     * @throws Exception if an error occurs.
142     */
143    public List<SolrInputDocument> indexResource(Resource resource, SolrInputDocument document, String documentType, String language, TraversableAmetysObject resourceRoot) throws Exception
144    {
145        // Resource id
146        document.addField(ID, resource.getId());
147        // Type is resource
148        document.addField(DOCUMENT_TYPE, documentType);
149        document.addField(PSEUDO_CONTENT_TYPES, PSEUDO_CONTENT_TYPE_VALUE_RESOURCE);
150        // The resource path.
151        document.setField(PATH, resource.getResourcePath());
152        document.addField(FILENAME, resource.getName());
153        
154        // Title
155        String title = StringUtils.substringBeforeLast(resource.getName(), ".");
156        // Index title like other string values (like content attributes)
157        CMSDataContext context = CMSDataContext.newInstance();
158        if (StringUtils.isNotEmpty(language))
159        {
160            context.withLocale(LocaleUtils.toLocale(language));
161        }
162        IndexableElementTypeHelper.indexStringValue(document, document, TITLE, title, context, getLogger());
163        // Add sort indexation
164        document.setField(TITLE_SORT, resource.getName());
165        document.addField(TITLE + "_s_sort", title);
166        // Add title to "full" (already added to "systemFull")
167        CMSDataContext fullContext = context.cloneContext()
168                                            .withIndexForFullTextField(true)
169                                            .withFullTextFieldName(SolrFieldNames.FULL);
170        IndexableElementTypeHelper.indexFulltextValue(document, title, fullContext);
171        
172        _populateDatesOfPage(resource, document);
173
174        // Mime types
175        document.addField(MIME_TYPES, resource.getMimeType());
176        // Length
177        document.addField(LENGTH, resource.getLength());
178        
179        AmetysObject root = resourceRoot == null ? ResourceHelper.getResourceRoot(resource) : resourceRoot;
180        document.addField(RESOURCE_ROOT_ID, root.getId());
181        
182        // Parents resource collections of the resource
183        _indexAncestorIds(resource, document);
184        
185        // Resource author
186        String author = UserIdentity.userIdentityToString(resource.getCreator());
187        if (StringUtils.isNotBlank(author))
188        {
189            document.setField(RESOURCE_CREATOR, author);
190        }
191        
192        // Hard-coded content type for facets.
193        // TODO Move to specific "embedded mode" method?
194        document.addField(ContentTypeSystemProperty.CONTENT_TYPES_SOLR_FIELD_NAME, CONTENT_TYPE_RESOURCE);
195        
196        // Indexation of ACL initial values
197        _solrIndexer.indexAclInitValues(resource, document);
198
199        indexResourceContent(resource, document, language);
200        
201        // Set the additional properties in the document.
202        List<SolrInputDocument> additionalIndexData = _populateAdditionalData(resource, document);
203        
204        return additionalIndexData;
205    }
206    
207    /**
208     * Populate the solr input document with dates from the resource
209     * @param resource The resource
210     * @param document The Solr document
211     */
212    protected void _populateDatesOfPage(Resource resource, SolrInputDocument document)
213    {
214        // Last modified
215        _getFormattedDate(resource.getLastModified()).ifPresent(
216            lastModified ->
217            {
218                // For 'new' search service
219                document.addField(LastModifiedSystemProperty.SOLR_FIELD_NAME, lastModified);
220                // For 'old' search service
221                document.addField(LastModifiedSystemProperty.SOLR_FIELD_NAME + "_dt", lastModified);
222                
223                // For 'new' search service => last validation, last major validation
224                document.addField(LastValidationSystemProperty.SOLR_FIELD_NAME, lastModified);
225                document.addField(LastMajorValidationSystemProperty.SOLR_FIELD_NAME, lastModified);
226            }
227        );
228        
229        // For 'new' search service => first validation
230        _getFormattedDate(resource.getCreationDate()).ifPresent(
231            creationDate ->
232            {
233                // For 'new' search service
234                document.addField(FirstValidationSystemProperty.SOLR_FIELD_NAME, creationDate);
235            }
236        );
237        
238        // Solr facet specific : dates-facet
239        _getFormattedDate(resource.getDCDate()).ifPresent(
240            formattedDate ->
241            {
242                document.setField(RESOURCE_DATE, formattedDate);
243                document.setField(DATE_FOR_SORTING, formattedDate);
244                document.setField(DATES_FACET, formattedDate);
245            }
246        );
247    }
248    
249    private Optional<String> _getFormattedDate(Date date)
250    {
251        return Optional.ofNullable(date)
252                .map(DateUtils::asZonedDateTime)
253                .map(DateUtils::zonedDateTimeToString);
254    }
255    
256    private void _indexAncestorIds(Resource resource, SolrInputDocument document)
257    {
258        // Ancestors
259        List<String> ancestorIds = new ArrayList<>();
260        AmetysObject parent = resource.getParent();
261        while (parent instanceof ResourceCollection)
262        {
263            ancestorIds.add(parent.getId());
264            parent = parent.getParent();
265        }
266        
267        document.addField(RESOURCE_ANCESTOR_IDS, ancestorIds);
268        
269        // Ancestors and self
270        List<String> ancestorAndSelfIds = new ArrayList<>();
271        ancestorAndSelfIds.add(resource.getId());
272        ancestorAndSelfIds.addAll(ancestorIds);
273        document.addField(RESOURCE_ANCESTOR_AND_SELF_IDS, ancestorAndSelfIds);
274    }
275    
276    /**
277     * Index a collection of resources.
278     * @param resourceCollection the resource collection to index.
279     * @param document The document to index into.
280     * @param language The current language.
281     * @throws Exception if an error occurs while indexing.
282     */
283    public void indexResourceCollection(ResourceCollection resourceCollection, SolrInputDocument document, String language) throws Exception
284    {
285        if (resourceCollection == null)
286        {
287            return;
288        }
289        
290        for (AmetysObject object : resourceCollection.getChildren())
291        {
292            if (object instanceof ResourceCollection)
293            {
294                indexResourceCollection((ResourceCollection) object, document, language);
295            }
296            else if (object instanceof Resource)
297            {
298                indexResourceContent((Resource) object, document, language);
299            }
300        }
301    }
302    
303    /**
304     * Index a resource content (text in case of a document, and Dublin Core metadata).
305     * @param resource The resource to index.
306     * @param document The document to index into.
307     * @param language The current language, can be null.
308     */
309    public void indexResourceContent(Resource resource, SolrInputDocument document, String language)
310    {
311        try (InputStream is = resource.getInputStream())
312        {
313            String value = _getResourceContent(resource);
314            
315            indexFulltextValue(document, value, language);
316            
317            if (StringUtils.isNotBlank(value))
318            {
319                int summaryEndIndex = value.lastIndexOf(' ', 200);
320                if (summaryEndIndex == -1)
321                {
322                    summaryEndIndex = value.length();
323                }
324                document.addField(EXCERPT, value.substring(0, summaryEndIndex) + (summaryEndIndex != value.length() ? "…" : ""));
325            }
326            
327            String[] dcSubject = resource.getDCSubject();
328            if (dcSubject != null)
329            {
330                for (String keyword : dcSubject)
331                {
332                    indexFulltextValue(document, keyword, language);
333                }
334            }
335
336            String desc = resource.getDCDescription();
337            if (desc != null)
338            {
339                indexFulltextValue(document, desc, language);
340            }
341            
342            // DC meta
343            indexDublinCoreMetadata(resource, document);
344        }
345        catch (Throwable e)
346        {
347            getLogger().error("Unable to index resource at " + resource.getPath(), e);
348        }
349    }
350    
351    private String _getResourceContent(Resource resource) throws Throwable
352    {
353        try (InputStream is = resource.getInputStream())
354        {
355            return _tika.parseToString(is);
356        }
357        catch (ZeroByteFileException e)
358        {
359            // Ignore it, the file is empty, nothing to do
360            return StringUtils.EMPTY;
361        }
362        catch (Throwable e)
363        {
364            throw e;
365        }
366    }
367    
368    /**
369     * Index a full-text value.
370     * @param document The document to index into.
371     * @param text The text to index.
372     * @param language The content language, can be null.
373     */
374    protected void indexFulltextValue(SolrInputDocument document, String text, String language)
375    {
376        CMSDataContext context = CMSDataContext.newInstance()
377                .withIndexForFullTextField(true); // Facultative here because not asked by the following methods, but a protection for the future
378        if (StringUtils.isNotEmpty(language))
379        {
380            context.withLocale(LocaleUtils.toLocale(language));
381        }
382        
383        // Index the document in systemFull
384        IndexableElementTypeHelper.indexFulltextValue(document, text, context);
385        
386        // Then in full
387        IndexableElementTypeHelper.indexFulltextValue(document, text, context.withFullTextFieldName(SolrFieldNames.FULL));
388    }
389    
390    ///////////////////////////////////////////////////////////////////////////
391    
392    /**
393     * Index Dublin core metadata.
394     * @param object the {@link DublinCoreAwareAmetysObject} holding Dublin Core metadata.
395     * @param document the solr input document to populate.
396     */
397    public void indexDublinCoreMetadata(DublinCoreAwareAmetysObject object, SolrInputDocument document)
398    {
399        _indexNonNullValue(document, DC_TITLE, object.getDCTitle());
400        _indexNonNullValue(document, DC_SUBJECT, object.getDCSubject());
401        _indexNonNullValue(document, DC_DESCRIPTION, object.getDCDescription());
402        _indexNonNullValue(document, DC_CONTRIBUTOR, object.getDCContributor());
403        _indexNonNullValue(document, DC_COVERAGE, object.getDCCoverage());
404        _indexNonNullValue(document, DC_CREATOR, object.getDCCreator());
405        String mimeType = _getDcFormatToIndex(object);
406        _indexNonNullValue(document, DC_FORMAT, mimeType);
407        _indexNonNullValue(document, DC_LANGUAGE, object.getDCLanguage());
408        _indexNonNullValue(document, DC_PUBLISHER, object.getDCPublisher());
409        _indexNonNullValue(document, DC_RIGHTS, object.getDCRights());
410        _indexNonNullValue(document, DC_DATE, _getFormattedDate(object.getDCDate()).orElse(null));
411        
412        SolrResourceGroupedMimeTypes.getGroup(mimeType)
413                .ifPresent(groupMimeType -> document.addField(RESOURCE_MIME_TYPE_GROUP, groupMimeType));
414    }
415    
416    private static String _getDcFormatToIndex(DublinCoreAwareAmetysObject object)
417    {
418        return Optional.of(object)
419                .map(DublinCoreAwareAmetysObject::getDCFormat)
420                // According to https://en.wikipedia.org/wiki/Media_type#Naming
421                // input format is:
422                // type "/" [tree "."] subtype ["+" suffix] *[";" parameter]
423                // just output the part without optional parameters
424                .map(mimeType -> StringUtils.substringBefore(mimeType, ";"))
425                // According to https://en.wikipedia.org/wiki/Media_type#Naming
426                // Types, subtypes, and parameter names are case-insensitive
427                .map(String::toLowerCase)
428                .orElse(null);
429    }
430    
431    private static void _indexNonNullValue(SolrInputDocument document, String fieldName, String value)
432    {
433        if (value != null)
434        {
435            document.addField(fieldName, value);
436        }
437    }
438    
439    private static void _indexNonNullValue(SolrInputDocument document, String fieldName, String[] values)
440    {
441        if (values != null)
442        {
443            for (String value : values)
444            {
445                document.addField(fieldName, value);
446            }
447        }
448    }
449    
450    /*private static void _indexNonNullValue(SolrInputDocument document, String fieldName, Date value)
451    {
452        if (value != null)
453        {
454            document.addField(fieldName, value);
455        }
456    }*/
457    
458    /**
459     * Populate the solr input document by adding fields to index.
460     * @param resource the resource to index.
461     * @param document the solr input document
462     * @return Additional documents created by additional property indexers
463     * @throws Exception if something goes wrong when processing the indexation of the page
464     */
465    protected List<SolrInputDocument> _populateAdditionalData(Resource resource, SolrInputDocument document) throws Exception
466    {
467        List<SolrInputDocument> additionnalDocs = new ArrayList<>();
468        
469        Collection<AdditionalDataIndexer> indexers = _additionalDataIndexerEP.getIndexers(AdditionalDataIndexer.TYPE_RESOURCE);
470        for (AdditionalDataIndexer indexer : indexers)
471        {
472            additionnalDocs.addAll(indexer.indexAdditionalDocuments(resource, document));
473        }
474        
475        return additionnalDocs;
476    }
477
478}