001/* 002 * Copyright 2017 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.ui.model.impl; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.Collections; 021import java.util.HashSet; 022import java.util.Iterator; 023import java.util.List; 024import java.util.Locale; 025import java.util.Set; 026import java.util.stream.Collectors; 027 028import org.apache.avalon.framework.configuration.Configurable; 029import org.apache.avalon.framework.configuration.Configuration; 030import org.apache.avalon.framework.configuration.ConfigurationException; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.apache.commons.lang3.StringUtils; 035 036import org.ametys.cms.contenttype.ContentType; 037import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 038import org.ametys.cms.contenttype.ContentTypesHelper; 039import org.ametys.cms.contenttype.MetadataDefinition; 040import org.ametys.cms.data.type.ModelItemTypeConstants; 041import org.ametys.cms.repository.Content; 042import org.ametys.cms.search.SearchField; 043import org.ametys.cms.search.content.ContentSearchHelper; 044import org.ametys.cms.search.model.MetadataResultField; 045import org.ametys.plugins.repository.model.RepeaterDefinition; 046import org.ametys.runtime.model.ElementDefinition; 047import org.ametys.runtime.model.ModelHelper; 048import org.ametys.runtime.model.ModelItem; 049import org.ametys.runtime.model.ModelItemContainer; 050import org.ametys.runtime.model.ModelItemGroup; 051import org.ametys.runtime.model.exception.UndefinedItemPathException; 052import org.ametys.runtime.model.type.ModelItemType; 053 054/** 055 * Default implementation of a search ui column for a content's attribute 056 */ 057public class MetadataSearchUIColumn extends AbstractSearchUIColumn implements MetadataResultField, Serviceable, Configurable 058{ 059 /** The content type extension point. */ 060 protected ContentTypeExtensionPoint _cTypeEP; 061 062 /** The content type helper. */ 063 protected ContentTypesHelper _cTypeHelper; 064 065 /** The search helper. */ 066 protected ContentSearchHelper _searchHelper; 067 068 /** The full attribute path. */ 069 protected String _fullAttributePath; 070 071 /** True if the attribute is joined, false otherwise. */ 072 protected boolean _joinedAttribute; 073 074 /** The types of the content on which this attribute column applies */ 075 protected Collection<String> _contentTypes; 076 077 /** The attribute represented by the column */ 078 protected ModelItem _attribute; 079 080 private boolean _isTypeContentWithMultilingualTitle; 081 082 @Override 083 public void service(ServiceManager manager) throws ServiceException 084 { 085 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 086 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 087 _searchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE); 088 } 089 090 @Override 091 public void configure(Configuration configuration) throws ConfigurationException 092 { 093 // The full path can contain "join" paths. 094 _fullAttributePath = configuration.getChild("metadata").getAttribute("path"); 095 096 Set<String> baseContentTypeIds = new HashSet<>(); 097 for (Configuration cTypeConf : configuration.getChild("contentTypes").getChildren("baseType")) 098 { 099 baseContentTypeIds.add(cTypeConf.getAttribute("id")); 100 } 101 102 List<ModelItem> modelItems = _configureModelItems(baseContentTypeIds, _fullAttributePath); 103 104 // Compute "join" and "multiple" status. 105 boolean joinedAttribute = false; 106 boolean multiple = false; 107 boolean multiLevelMultiple = false; 108 109 Iterator<ModelItem> itemsIterator = modelItems.iterator(); 110 while (itemsIterator.hasNext()) 111 { 112 ModelItem modelItem = itemsIterator.next(); 113 ModelItemType type = modelItem.getType(); 114 // The column has multiple values if the full path contains a multiple attribute or a repeater. 115 if (_isAttributeMultiple(modelItem)) 116 { 117 multiple = true; 118 if (itemsIterator.hasNext()) 119 { 120 multiLevelMultiple = true; 121 } 122 } 123 // The column represents a "joined" value if it has a content attribute (except if it's the last one). 124 if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(type.getId()) && itemsIterator.hasNext()) 125 { 126 joinedAttribute = true; 127 } 128 } 129 130 setJoinedAttribute(joinedAttribute); 131 setMultiple(multiple); 132 133 _attribute = modelItems.get(modelItems.size() - 1); 134 135 MetadataDefinition metadataDefinition = _getMetadataDefinition(baseContentTypeIds, _fullAttributePath); 136 setId(_fullAttributePath); 137 setType(metadataDefinition.getType()); 138 setLabel(_configureI18nizableText(configuration.getChild("label", false), _attribute.getLabel())); 139 setDescription(_configureI18nizableText(configuration.getChild("description", false), _attribute.getDescription())); 140 setWidth(configuration.getChild("width").getValueAsInteger(200)); 141 setHidden(configuration.getChild("hidden").getValueAsBoolean(false)); 142 143 setValidator(metadataDefinition.getValidator()); 144 setEnumerator(metadataDefinition.getEnumerator()); 145 setWidget(metadataDefinition.getWidget()); 146 setWidgetParameters(metadataDefinition.getWidgetParameters()); 147 setContentTypeId(metadataDefinition.getContentType()); 148 149 configureRenderer(configuration.getChild("renderer").getValue(null), _attribute); 150 configureConverter(configuration.getChild("converter").getValue(null), _attribute); 151 152 boolean editable = _isEditable(configuration, _attribute, multiLevelMultiple); 153 setEditable(editable); 154 155 boolean sortable = _isSortable(configuration, _attribute, multiple); 156 setSortable(sortable); 157 158 if (sortable) 159 { 160 setDefaultSorter(configuration.getChild("default-sorter").getValue(null)); 161 } 162 163 List<String> contentTypeIds = new ArrayList<>(); 164 ModelItem firstModelItem = modelItems.get(0); 165 if (firstModelItem.getModel() != null) 166 { 167 // The title attribute has no model 168 contentTypeIds.add(firstModelItem.getModel().getId()); 169 } 170 setContentTypes(contentTypeIds); 171 172 setTypeContentWithMultilingualTitle(_searchHelper.isTitleMultilingual(_attribute)); 173 } 174 175 /** 176 * Computed sortable status of this column from its attribute definition and configuration 177 * @param configuration the column's configuration 178 * @param attribute the attribute definition 179 * @param multiplePath if path is multiple 180 * @return true if this column is sortable 181 */ 182 protected boolean _isSortable(Configuration configuration, ModelItem attribute, boolean multiplePath) 183 { 184 boolean sortable = configuration.getChild("sortable").getValueAsBoolean(true); 185 boolean allowSortOnMultipleJoin = configuration.getChild("allow-sort-on-multiple-join").getValueAsBoolean(false); 186 187 return sortable 188 && (allowSortOnMultipleJoin && !_isAttributeMultiple(attribute) // if path is not multiple, but an intermediate in the path is, it is OK => consider as sortable 189 || !allowSortOnMultipleJoin && !multiplePath) // if path is multiple => do not consider as sortable 190 && _isSortableMetadata(attribute); 191 192 } 193 194 /** 195 * Computed editable status of this column from its attribute definition and configuration 196 * @param configuration the column's configuration 197 * @param attribute the attribute definition 198 * @param multiLevelMultiple if path contains a multiple attrbute or a repeater. 199 * @return true if this column is editable 200 */ 201 protected boolean _isEditable(Configuration configuration, ModelItem attribute, boolean multiLevelMultiple) 202 { 203 boolean editable = configuration.getChild("editable").getValueAsBoolean(true); 204 205 return editable && !multiLevelMultiple && isEditionAllowed(attribute); 206 } 207 208 private List<ModelItem> _configureModelItems(Set<String> contentTypeIds, String attributePath) throws ConfigurationException 209 { 210 List<ModelItem> modelItems; 211 212 if (!contentTypeIds.isEmpty()) 213 { 214 try 215 { 216 Set<ContentType> contentTypes = contentTypeIds.stream() 217 .map(_cTypeEP::getExtension) 218 .collect(Collectors.toSet()); 219 modelItems = ModelHelper.getAllModelItemsInPath(attributePath, contentTypes); 220 } 221 catch (UndefinedItemPathException e) 222 { 223 throw new ConfigurationException("Unknown attribute '" + attributePath + "' in content types '" + StringUtils.join(contentTypeIds, ", ") + "'", e); 224 } 225 } 226 else if (Content.ATTRIBUTE_TITLE.equals(attributePath)) 227 { 228 // Only the title attribute is allowed if the base content type is null. 229 modelItems = Collections.singletonList(_cTypeHelper.getTitleAttributeDefinition()); 230 } 231 else 232 { 233 throw new ConfigurationException("The attribute '" + attributePath + "' is forbidden when no content type is specified: only '" + Content.ATTRIBUTE_TITLE + "' can be used."); 234 } 235 236 return modelItems; 237 } 238 239 private MetadataDefinition _getMetadataDefinition(Set<String> contentTypeIds, String metadataPath) 240 { 241 if (!contentTypeIds.isEmpty()) 242 { 243 String[] cTypes = contentTypeIds.toArray(String[]::new); 244 return _cTypeHelper.getMetadataDefinition(metadataPath, cTypes, new String[0]); 245 } 246 else 247 { 248 return _cTypeHelper.getTitleMetadataDefinition(); 249 } 250 } 251 252 /** 253 * Determines if the inline edition is allowed 254 * @param attribute The attribute definition 255 * @return true if the attribute is editable 256 */ 257 protected boolean isEditionAllowed(ModelItem attribute) 258 { 259 if (isJoinedAttribute()) 260 { 261 // attribute is not editable if it is on another content 262 return false; 263 } 264 265 if (ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(attribute.getType().getId())) 266 { 267 // richtext are never editable inline 268 return false; 269 } 270 271 if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(attribute.getType().getId())) 272 { 273 // disallow edition for repeater which contains richtext 274 return !ModelHelper.hasModelItemOfType((ModelItemContainer) attribute, ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID); 275 } 276 277 return true; 278 } 279 280 /** 281 * Configure the column renderer. 282 * @param renderer A specific renderer. If null, it will be deduced from the attribute definition. 283 * @param attribute The attribute definition. 284 */ 285 protected void configureRenderer(String renderer, ModelItem attribute) 286 { 287 String typeId = attribute.getType().getId(); 288 if (renderer != null) 289 { 290 setRenderer(renderer); 291 } 292 else if (Content.ATTRIBUTE_TITLE.equals(getFieldPath()) && org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(typeId)) 293 { 294 setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderTitle"); 295 } 296 else if (Content.ATTRIBUTE_TITLE.equals(getFieldPath()) && ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(typeId)) 297 { 298 setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderMultilingualTitle"); 299 } 300 else if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(typeId)) 301 { 302 setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderRepeater"); 303 } 304 } 305 306 /** 307 * Configure the column converter. 308 * @param converter A specific converter. If null, it will be deduced from the metadata definition. 309 * @param attribute The attribute definition. 310 */ 311 protected void configureConverter(String converter, ModelItem attribute) 312 { 313 String typeId = attribute.getType().getId(); 314 if (converter != null) 315 { 316 setConverter(converter); 317 } 318 else if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(typeId)) 319 { 320 setConverter("Ametys.plugins.cms.search.SearchGridHelper.convertContent"); 321 } 322 else if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(typeId)) 323 { 324 setConverter("Ametys.plugins.cms.search.SearchGridHelper.convertRepeater"); 325 } 326 } 327 328 private boolean _isAttributeMultiple(ModelItem attribute) 329 { 330 return (attribute instanceof ElementDefinition && ((ElementDefinition) attribute).isMultiple()) 331 || attribute instanceof RepeaterDefinition; 332 } 333 334 /** 335 * Set the attribute path 336 * @param attributePath the path to attribute 337 */ 338 public void setFieldPath(String attributePath) 339 { 340 _fullAttributePath = attributePath; 341 } 342 343 /** 344 * Get the path of attribute (separated by '/') 345 * @return the path of attribute 346 */ 347 public String getFieldPath() 348 { 349 return _fullAttributePath; 350 } 351 352 /** 353 * Get the content types of the contents on which this attribute column applies 354 * @return the content types 355 */ 356 public Collection<String> getContentTypes() 357 { 358 return _contentTypes; 359 } 360 361 /** 362 * Set the content types of the contents on which this attribute column applies 363 * @param contentTypes the content types 364 */ 365 public void setContentTypes(Collection<String> contentTypes) 366 { 367 _contentTypes = contentTypes; 368 } 369 370 /** 371 * Determines if this column is a path to a joined attribute 372 * @return true if is a joined attribute 373 */ 374 public boolean isJoinedAttribute() 375 { 376 return _joinedAttribute; 377 } 378 379 /** 380 * Set if this column is a path to a joined attribute 381 * @param joinedAttribute true if is a joined attribute 382 */ 383 public void setJoinedAttribute(boolean joinedAttribute) 384 { 385 _joinedAttribute = joinedAttribute; 386 } 387 388 /** 389 * Determines if this column represents a CONTENT attribute with a multilingual title 390 * @return true if this column represents a CONTENT attribute with a multilingual title 391 */ 392 public boolean isTypeContentWithMultilingualTitle() 393 { 394 return _isTypeContentWithMultilingualTitle; 395 } 396 397 /** 398 * Set if this column represents a CONTENT attribute with a multilingual title 399 * @param multilingual true if this column represents a CONTENT attribute with a multilingual title 400 */ 401 public void setTypeContentWithMultilingualTitle(boolean multilingual) 402 { 403 _isTypeContentWithMultilingualTitle = multilingual; 404 } 405 406 @Override 407 public Object getValue(Content content, Locale defaultLocale) 408 { 409 return _searchHelper.getAttributeValue(content, getFieldPath(), _attribute, defaultLocale, false); 410 } 411 412 @Override 413 public Object getFullValue(Content content, Locale defaultLocale) 414 { 415 return _searchHelper.getAttributeValue(content, getFieldPath(), _attribute, defaultLocale, true); 416 } 417 418 @Override 419 public SearchField getSearchField() 420 { 421 if (isJoinedAttribute()) 422 { 423 return _searchHelper.getSearchField(getContentTypes(), getFieldPath()).orElse(null); 424 } 425 else 426 { 427 return _searchHelper.getMetadataSearchField(getFieldPath(), getType(), isTypeContentWithMultilingualTitle()); 428 } 429 } 430 431 /** 432 * Determines if the column is sortable according its attribute definition 433 * @param attribute the attribute definition 434 * @return true if type is sortable 435 */ 436 @SuppressWarnings("static-access") 437 protected boolean _isSortableMetadata(ModelItem attribute) 438 { 439 switch (attribute.getType().getId()) 440 { 441 case ModelItemTypeConstants.STRING_TYPE_ID: 442 case ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID: 443 case ModelItemTypeConstants.LONG_TYPE_ID: 444 case ModelItemTypeConstants.DATE_TYPE_ID: 445 case ModelItemTypeConstants.DATETIME_TYPE_ID: 446 case ModelItemTypeConstants.BOOLEAN_TYPE_ID: 447 case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID: 448 case ModelItemTypeConstants.DOUBLE_TYPE_ID: 449 case ModelItemTypeConstants.USER_ELEMENT_TYPE_ID: 450 case ModelItemTypeConstants.REFERENCE_ELEMENT_TYPE_ID: 451 return true; 452 case ModelItemTypeConstants.COMPOSITE_TYPE_ID: 453 case ModelItemTypeConstants.REPEATER_TYPE_ID: 454 case ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID: 455 case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID: 456 case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID: 457 default: 458 return false; 459 } 460 } 461}