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}