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