001/* 002 * Copyright 2016 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.search.content; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.List; 021import java.util.Optional; 022import java.util.Set; 023import java.util.stream.Collectors; 024 025import org.apache.avalon.framework.component.Component; 026import org.apache.avalon.framework.context.Context; 027import org.apache.avalon.framework.context.ContextException; 028import org.apache.avalon.framework.context.Contextualizable; 029import org.apache.avalon.framework.service.ServiceException; 030import org.apache.avalon.framework.service.ServiceManager; 031import org.apache.avalon.framework.service.Serviceable; 032import org.apache.commons.lang3.StringUtils; 033 034import org.ametys.cms.contenttype.ContentType; 035import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 036import org.ametys.cms.contenttype.ContentTypesHelper; 037import org.ametys.cms.contenttype.MetadataType; 038import org.ametys.cms.data.type.ModelItemTypeConstants; 039import org.ametys.cms.model.ContentElementDefinition; 040import org.ametys.cms.repository.Content; 041import org.ametys.cms.search.SearchField; 042import org.ametys.cms.search.model.SystemProperty; 043import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 044import org.ametys.cms.search.solr.field.BooleanSearchField; 045import org.ametys.cms.search.solr.field.ContentSearchField; 046import org.ametys.cms.search.solr.field.DateSearchField; 047import org.ametys.cms.search.solr.field.DoubleSearchField; 048import org.ametys.cms.search.solr.field.JoinedSystemSearchField; 049import org.ametys.cms.search.solr.field.LongSearchField; 050import org.ametys.cms.search.solr.field.MultilingualStringSearchField; 051import org.ametys.cms.search.solr.field.StringSearchField; 052import org.ametys.plugins.repository.model.RepeaterDefinition; 053import org.ametys.runtime.model.ModelHelper; 054import org.ametys.runtime.model.ModelItem; 055import org.ametys.runtime.model.ModelItemAccessor; 056import org.ametys.runtime.model.ModelItemGroup; 057import org.ametys.runtime.model.ModelViewItem; 058import org.ametys.runtime.model.ViewItem; 059import org.ametys.runtime.model.ViewItemAccessor; 060import org.ametys.runtime.model.exception.UndefinedItemPathException; 061import org.ametys.runtime.model.type.ModelItemType; 062import org.ametys.runtime.plugin.component.AbstractLogEnabled; 063 064/** 065 * Component which helps content searching by providing a simple way to access 066 * content properties (either attributes or properties). 067 */ 068public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 069{ 070 /** The component role. */ 071 public static final String ROLE = ContentSearchHelper.class.getName(); 072 073 /** The content type extension point. */ 074 private ContentTypeExtensionPoint _cTypeEP; 075 076 /** The content type helper. */ 077 private ContentTypesHelper _cTypeHelper; 078 079 /** The system property extension point. */ 080 private SystemPropertyExtensionPoint _sysPropEP; 081 082 /** Content Types helper */ 083 private ContentTypesHelper _contentTypesHelper; 084 085 private Context _context; 086 087 @Override 088 public void contextualize(Context context) throws ContextException 089 { 090 _context = context; 091 } 092 093 @Override 094 public void service(ServiceManager manager) throws ServiceException 095 { 096 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 097 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 098 _sysPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 099 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 100 } 101 102 /** 103 * Get a {@link SearchField} corresponding to a metadata. 104 * @param joinPaths The join paths 105 * @param metadataPath The metadata path. 106 * @param metadataType The metadata type. 107 * @param isTypeContentWithMultilingualTitle <code>true</code> if the type is Content and the linked contents have multilingual titles 108 * @return the search field. 109 */ 110 public SearchField getMetadataSearchField(List<String> joinPaths, String metadataPath, MetadataType metadataType, boolean isTypeContentWithMultilingualTitle) 111 { 112 switch (metadataType) 113 { 114 case STRING: 115 case USER: 116 case REFERENCE: 117 return new StringSearchField(joinPaths, metadataPath); 118 case CONTENT: 119 case SUB_CONTENT: 120 return new ContentSearchField(joinPaths, metadataPath, isTypeContentWithMultilingualTitle, Optional.ofNullable(_context)); 121 case LONG: 122 return new LongSearchField(joinPaths, metadataPath); 123 case DOUBLE: 124 return new DoubleSearchField(joinPaths, metadataPath); 125 case BOOLEAN: 126 return new BooleanSearchField(joinPaths, metadataPath); 127 case DATE: 128 case DATETIME: 129 return new DateSearchField(joinPaths, metadataPath); 130 case MULTILINGUAL_STRING: 131 return new MultilingualStringSearchField(joinPaths, metadataPath, Optional.ofNullable(_context)); 132 case COMPOSITE: 133 case BINARY: 134 case FILE: 135 case RICH_TEXT: 136 default: 137 return null; 138 } 139 } 140 141 /** 142 * Get a {@link SearchField} from a field name in a batch of content types. 143 * @param contentTypes The content types, can be empty to search on any content type. 144 * In that case, only the title metadata and system properties will be usable in sort and facets specs. 145 * @param fieldPath The field path, can be either a system property ID or a indexing field name or path. 146 * @return The {@link SearchField} corresponding to the field path, or an {@link Optional#empty() empty optional} if not found 147 */ 148 public Optional<SearchField> getSearchField(Collection<String> contentTypes, String fieldPath) 149 { 150 // Root system property 151 if (!fieldPath.contains(ModelItem.ITEM_PATH_SEPARATOR) && _sysPropEP.hasExtension(fieldPath)) 152 { 153 return Optional.ofNullable(_sysPropEP.getExtension(fieldPath).getSearchField()); 154 } 155 156 Set<String> commonContentTypeIds = _cTypeHelper.getCommonAncestors(contentTypes); 157 if (!commonContentTypeIds.isEmpty()) 158 { 159 try 160 { 161 List<String> joinPaths = computeJoinPaths(fieldPath, commonContentTypeIds, false); 162 163 String fieldName = fieldPath.contains(ModelItem.ITEM_PATH_SEPARATOR) ? StringUtils.substringAfterLast(fieldPath, ModelItem.ITEM_PATH_SEPARATOR) : fieldPath; 164 if (_sysPropEP.hasExtension(fieldName)) 165 { 166 return Optional.of(new JoinedSystemSearchField(joinPaths, _sysPropEP.getExtension(fieldName).getSearchField())); 167 } 168 else 169 { 170 Collection<ContentType> commonContentTypes = commonContentTypeIds.stream() 171 .map(_cTypeEP::getExtension) 172 .collect(Collectors.toList()); 173 ModelItem modelItem = ModelHelper.getModelItem(fieldPath, commonContentTypes); 174 return Optional.of(_getModelItemSearchField(joinPaths, modelItem, isTitleMultilingual(modelItem))); 175 } 176 } 177 catch (UndefinedItemPathException e) 178 { 179 throw new IllegalArgumentException("Search field with path '" + fieldPath + "' refers to an unknown attribute or property", e); 180 } 181 } 182 else if (fieldPath.equals(Content.ATTRIBUTE_TITLE)) 183 { 184 // No specific content type: allow only title. 185 ModelItem titleDefinition = _contentTypesHelper.getTitleAttributeDefinition(); 186 return Optional.of(_getModelItemSearchField(null, titleDefinition, false)); 187 } 188 189 return Optional.empty(); 190 } 191 192 /** 193 * Retrieves the join paths from the given data path 194 * @param dataPath the data path 195 * @param contentTypeIds the ids of the content types containing the items of the given path 196 * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise. 197 * @return the join paths 198 * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors 199 */ 200 public List<String> computeJoinPaths(String dataPath, Set<String> contentTypeIds, boolean addLast) throws UndefinedItemPathException 201 { 202 Collection<ContentType> contentTypes = contentTypeIds.stream() 203 .map(_cTypeEP::getExtension) 204 .collect(Collectors.toList()); 205 206 return _computeJoinPaths(dataPath, StringUtils.EMPTY, contentTypes, addLast); 207 } 208 209 /** 210 * Retrieves the join paths from the given data path 211 * @param dataPath the data path 212 * @param prefix the prefix of current join path 213 * @param modelItemAccessor the model item to access the items of the given path 214 * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise. 215 * @return the join paths 216 * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessor 217 */ 218 protected List<String> _computeJoinPaths(String dataPath, String prefix, ModelItemAccessor modelItemAccessor, boolean addLast) throws UndefinedItemPathException 219 { 220 return _computeJoinPaths(dataPath, prefix, List.of(modelItemAccessor), addLast); 221 } 222 223 /** 224 * Retrieves the join paths from the given data path 225 * @param dataPath the data path 226 * @param prefix the prefix of current join path 227 * @param modelItemAccessors the model items to access the items of the given path 228 * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise. 229 * @return the join paths 230 * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors 231 */ 232 protected List<String> _computeJoinPaths(String dataPath, String prefix, Collection<? extends ModelItemAccessor> modelItemAccessors, boolean addLast) throws UndefinedItemPathException 233 { 234 String[] pathSegments = StringUtils.split(dataPath, ModelItem.ITEM_PATH_SEPARATOR); 235 if (pathSegments == null || pathSegments.length < 1) 236 { 237 throw new IllegalArgumentException("Unable to retrieve the data at the given path. This path is empty."); 238 } 239 else if (pathSegments.length == 1) 240 { 241 return addLast ? List.of(prefix + dataPath) : List.of(); 242 } 243 else 244 { 245 List<String> joinPaths = new ArrayList<>(); 246 String subDataPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 247 ModelItem modelItem = ModelHelper.getModelItem(pathSegments[0], modelItemAccessors); 248 if (modelItem instanceof ContentElementDefinition contentElementDefinition) 249 { 250 joinPaths.add(prefix + pathSegments[0]); 251 joinPaths.addAll(_computeJoinPaths(subDataPath, StringUtils.EMPTY, contentElementDefinition, addLast)); 252 } 253 else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition) 254 { 255 joinPaths.add(prefix + pathSegments[0]); 256 joinPaths.addAll(_computeJoinPaths(subDataPath, StringUtils.EMPTY, repeaterDefinition, addLast)); 257 } 258 else if (modelItem instanceof ModelItemAccessor modelItemAccessor) 259 { 260 joinPaths.addAll(_computeJoinPaths(subDataPath, pathSegments[0] + ModelItem.ITEM_PATH_SEPARATOR, modelItemAccessor, addLast)); 261 } 262 263 return joinPaths; 264 } 265 } 266 267 /** 268 * Get a {@link SearchField} from a model view item. 269 * @param modelViewItem The model view item 270 * @return The {@link SearchField} corresponding to given model view item 271 */ 272 public SearchField getSearchField(ModelViewItem modelViewItem) 273 { 274 List<String> joinPaths = _computeJoinPaths(modelViewItem); 275 ModelItem modelItem = modelViewItem.getDefinition(); 276 277 if (modelItem instanceof SystemProperty systemProperty) 278 { 279 SearchField systemPropertySearchField = systemProperty.getSearchField(); 280 if (joinPaths.isEmpty()) 281 { 282 return systemPropertySearchField; 283 } 284 else 285 { 286 return new JoinedSystemSearchField(joinPaths, systemPropertySearchField); 287 } 288 } 289 else 290 { 291 return _getModelItemSearchField(joinPaths, modelItem, false); 292 } 293 } 294 295 /** 296 * Retrieves the join paths from the given view item 297 * @param viewItem the view item 298 * @return the join paths 299 */ 300 public List<String> _computeJoinPaths(ViewItem viewItem) 301 { 302 List<String> joinPaths = new ArrayList<>(); 303 String currentJoinPath = StringUtils.EMPTY; 304 305 ViewItemAccessor parent = viewItem.getParent(); 306 while (parent != null) 307 { 308 if (parent instanceof ModelViewItem parentModelViewItem) 309 { 310 ModelItem modelItem = parentModelViewItem.getDefinition(); 311 if (modelItem instanceof ContentElementDefinition || modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition) 312 { 313 if (StringUtils.isNotEmpty(currentJoinPath)) 314 { 315 joinPaths.add(0, currentJoinPath); 316 } 317 currentJoinPath = modelItem.getName(); 318 } 319 else if (StringUtils.isNotEmpty(currentJoinPath)) 320 { 321 currentJoinPath = modelItem.getName() + ModelItem.ITEM_PATH_SEPARATOR + currentJoinPath; 322 } 323 } 324 325 parent = parent instanceof ViewItem parentViewItem ? parentViewItem.getParent() : null; 326 if (parent == null && StringUtils.isNotEmpty(currentJoinPath)) 327 { 328 joinPaths.add(0, currentJoinPath); 329 } 330 } 331 332 return joinPaths; 333 } 334 335 /** 336 * Determines if the given model item represents an attribute of type content with contents with multilingual titles 337 * @param modelItem The model item 338 * @return <code>true</code> if the given model item represents an attribute of type content with contents with multilingual titles 339 */ 340 public boolean isTitleMultilingual(ModelItem modelItem) 341 { 342 return Optional.ofNullable(modelItem) 343 .filter(ContentElementDefinition.class::isInstance) 344 .map(ContentElementDefinition.class::cast) 345 .map(ContentElementDefinition::getContentTypeId) 346 .map(_cTypeEP::getExtension) 347 .filter(cType -> cType.hasModelItem(Content.ATTRIBUTE_TITLE)) 348 .map(cType -> cType.getModelItem(Content.ATTRIBUTE_TITLE)) 349 .map(ModelItem::getType) 350 .map(ModelItemType::getId) 351 .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals) 352 .orElse(false); 353 } 354 355 @SuppressWarnings("static-access") 356 // FIXME CMS-11713: put the SearchField implementation in types? 357 private SearchField _getModelItemSearchField(List<String> joinPaths, ModelItem modelItem, boolean isTypeContentWithMultilingualTitle) 358 { 359 String fieldname = computeFieldPath(modelItem); 360 361 switch (modelItem.getType().getId()) 362 { 363 case ModelItemTypeConstants.STRING_TYPE_ID: 364 case ModelItemTypeConstants.USER_ELEMENT_TYPE_ID: 365 case ModelItemTypeConstants.REFERENCE_ELEMENT_TYPE_ID: 366 return new StringSearchField(joinPaths, fieldname); 367 case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID: 368 return new ContentSearchField(joinPaths, fieldname, isTypeContentWithMultilingualTitle, Optional.ofNullable(_context)); 369 case ModelItemTypeConstants.LONG_TYPE_ID: 370 return new LongSearchField(joinPaths, fieldname); 371 case ModelItemTypeConstants.DOUBLE_TYPE_ID: 372 return new DoubleSearchField(joinPaths, fieldname); 373 case ModelItemTypeConstants.BOOLEAN_TYPE_ID: 374 return new BooleanSearchField(joinPaths, fieldname); 375 case ModelItemTypeConstants.DATE_TYPE_ID: 376 case ModelItemTypeConstants.DATETIME_TYPE_ID: 377 return new DateSearchField(joinPaths, fieldname); 378 case ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID: 379 return new MultilingualStringSearchField(joinPaths, fieldname, Optional.ofNullable(_context)); 380 case ModelItemTypeConstants.COMPOSITE_TYPE_ID: 381 case ModelItemTypeConstants.REPEATER_TYPE_ID: 382 case ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID: 383 case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID: 384 case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID: 385 default: 386 return null; 387 } 388 } 389 390 /** 391 * Get the field path for the model item. 392 * @param definition the model item 393 * @return the field path 394 */ 395 public String computeFieldPath(ModelItem definition) 396 { 397 StringBuilder path = new StringBuilder(); 398 399 ModelItemGroup parent = definition.getParent(); 400 if (parent != null && !(parent instanceof RepeaterDefinition)) 401 { 402 path.append(computeFieldPath(parent)).append(ModelItem.ITEM_PATH_SEPARATOR); 403 } 404 405 path.append(definition.getName()); 406 return path.toString(); 407 } 408 409}