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