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