001/* 002 * Copyright 2019 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; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.List; 021import java.util.Optional; 022import java.util.Set; 023import java.util.regex.Matcher; 024import java.util.regex.Pattern; 025import java.util.stream.Collectors; 026import java.util.stream.Stream; 027 028import org.apache.avalon.framework.component.Component; 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.model.ContentElementDefinition; 038import org.ametys.cms.repository.Content; 039import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 040import org.ametys.plugins.repository.model.CompositeDefinition; 041import org.ametys.plugins.repository.model.RepeaterDefinition; 042import org.ametys.plugins.repository.model.ViewHelper; 043import org.ametys.runtime.i18n.I18nizableText; 044import org.ametys.runtime.model.ModelHelper; 045import org.ametys.runtime.model.ModelItem; 046import org.ametys.runtime.model.ModelItemAccessor; 047import org.ametys.runtime.model.ModelItemContainer; 048import org.ametys.runtime.model.ModelItemGroup; 049import org.ametys.runtime.model.ModelViewItem; 050import org.ametys.runtime.model.ModelViewItemGroup; 051import org.ametys.runtime.model.View; 052import org.ametys.runtime.model.ViewItem; 053import org.ametys.runtime.model.ViewItemAccessor; 054 055/** 056 * Component providing methods to manipulate {@link Column columns} for search models. 057 */ 058public class ColumnHelper implements Component, Serviceable 059{ 060 /** The Avalon role */ 061 public static final String ROLE = ColumnHelper.class.getName(); 062 063 private static final String __VIEW_REFERENCE_REGEX = "\\[(.+)\\]"; 064 private static final Pattern __VIEW_REFERENCE_PATTERN = Pattern.compile("^[^\\[]+" + __VIEW_REFERENCE_REGEX + "$"); 065 066 /** The content type extension point */ 067 protected ContentTypeExtensionPoint _cTypeEP; 068 069 /** The content type helper. */ 070 protected ContentTypesHelper _cTypeHelper; 071 072 /** The system property extension point. */ 073 protected SystemPropertyExtensionPoint _systemPropEP; 074 075 @Override 076 public void service(ServiceManager manager) throws ServiceException 077 { 078 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 079 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 080 _systemPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 081 } 082 083 /** 084 * From a string representing columns, returns the list of column ids with their (optional) labels. 085 * @param columnsStr The columns as a string 086 * @param contentTypeIds The common content type 087 * @return the list of column ids with their (optional) labels. 088 */ 089 public List<Column> getColumns(String columnsStr, Set<String> contentTypeIds) 090 { 091 return _getColumns(List.of(StringUtils.split(columnsStr, ',')), contentTypeIds); 092 } 093 /** 094 * From a list of string representing columns, returns the list of column ids with their (optional) labels. 095 * @param columns The columns 096 * @param contentTypeIds The common content type 097 * @return the list of column ids with their (optional) labels. 098 */ 099 public List<Column> getColumns(List<String> columns, Set<String> contentTypeIds) 100 { 101 return _getColumns(columns, contentTypeIds); 102 } 103 104 private List<Column> _getColumns(List<String> columns, Set<String> contentTypeIds) 105 { 106 Set<ContentType> contentTypes = contentTypeIds.stream() 107 .map(_cTypeEP::getExtension) 108 .collect(Collectors.toSet()); 109 110 ColumnTransformer columnTransformer = new ColumnTransformer(contentTypes, this); 111 112 // in StringUtils.split, adjacent separators are treated as one separator, so col cannot be empty 113 // but it still can be whitespaces only, just ignore them silently 114 return columns.stream() 115 .filter(StringUtils::isNotBlank) 116 .map(this::_leftTrim) // because we do not want a column named " as " to be split then, the "as" should be considered as the column id 117 .map(col -> col.split("(?i) AS ", 2)) 118 .map(arr -> 119 { 120 // col is never empty, so arr.length cannot be 0 121 String colId = arr[0].trim().replace('.', '/'); 122 if (arr.length == 2) 123 { 124 String columnLabel = Optional.ofNullable(arr[1]) 125 .map(String::trim) 126 .map(org.ametys.core.util.StringUtils::escapeHTML) 127 .orElse(null); 128 return new Column(colId, columnLabel); 129 } 130 else 131 { 132 return new Column(colId, null); 133 } 134 }) 135 .flatMap(columnTransformer::transform) 136 .distinct() 137 .collect(Collectors.toList()); 138 } 139 140 private String _leftTrim(String s) 141 { 142 return s.replaceAll("^\\s+", ""); 143 } 144 145 boolean isViewReference(String path) 146 { 147 Matcher matcher = __VIEW_REFERENCE_PATTERN.matcher(path); 148 return matcher.matches(); 149 } 150 151 List<String> getViewReferenceColumnPaths(Set<ContentType> commonContentTypes, String attributePath) 152 { 153 if (commonContentTypes.isEmpty()) 154 { 155 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed)."); 156 } 157 158 Matcher matcher = __VIEW_REFERENCE_PATTERN.matcher(attributePath); 159 if (!matcher.matches()) 160 { 161 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax."); 162 } 163 String viewName = matcher.group(1); 164 165 // Get the attribute without the view reference 166 String path = attributePath.replaceAll(__VIEW_REFERENCE_REGEX, StringUtils.EMPTY); 167 if (!ModelHelper.hasModelItem(path, commonContentTypes)) 168 { 169 throw new IllegalArgumentException("The given attribute path '" + path + "' is not defined in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'."); 170 } 171 172 ModelItem modelItem = ModelHelper.getModelItem(path, commonContentTypes); 173 if (!(modelItem instanceof ContentElementDefinition definition)) 174 { 175 throw new IllegalArgumentException("The given attribute path '" + path + "' type is not content in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'."); 176 } 177 178 String contentTypeId = definition.getContentTypeId(); 179 if (StringUtils.isEmpty(contentTypeId)) 180 { 181 throw new IllegalArgumentException("The attribute at path '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "' does not specify any content type."); 182 } 183 184 ContentType contentType = _cTypeEP.getExtension(contentTypeId); 185 View referencedView = contentType.getView(viewName); 186 if (referencedView == null) 187 { 188 throw new IllegalArgumentException("The view '" + viewName + "' does not exist in content type '" + contentTypeId + "'."); 189 } 190 191 // Build the list of paths from the referenced view 192 return _getColumnPathsFromViewItemAccessor(referencedView, path + ModelItem.ITEM_PATH_SEPARATOR); 193 } 194 195 private List<String> _getColumnPathsFromViewItemAccessor(ViewItemAccessor viewItemAccessor, String prefix) 196 { 197 List<String> columnPaths = new ArrayList<>(); 198 199 ViewHelper.visitView(viewItemAccessor, 200 (element, definition) -> { 201 // simple element 202 String columnPath = prefix + definition.getName(); 203 204 if (element instanceof ViewItemAccessor accessor && !accessor.getViewItems().isEmpty()) 205 { 206 String newPrefix = columnPath + ModelItem.ITEM_PATH_SEPARATOR; 207 columnPaths.addAll(_getColumnPathsFromViewItemAccessor(accessor, newPrefix)); 208 } 209 else 210 { 211 columnPaths.add(columnPath); 212 } 213 }, 214 (group, definition) -> { 215 // composite 216 if (!group.getViewItems().isEmpty()) 217 { 218 String newPrefix = prefix + definition.getName() + ModelItem.ITEM_PATH_SEPARATOR; 219 columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, newPrefix)); 220 } 221 }, 222 (group, definition) -> { 223 // repeater 224 columnPaths.add(prefix + definition.getName()); 225 }, 226 group -> columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, prefix))); 227 228 return columnPaths; 229 } 230 231 boolean isWildcardColumn(String path) 232 { 233 return "*".equals(path) || path.endsWith("/*"); 234 } 235 236 List<String> getWildcardAttributeColumnPaths(Set<ContentType> commonContentTypes, String attributePath) throws IllegalArgumentException 237 { 238 if (!isWildcardColumn(attributePath)) 239 { 240 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax."); 241 } 242 243 if (attributePath.endsWith("/*") && commonContentTypes.isEmpty()) 244 { 245 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed)."); 246 } 247 248 if (commonContentTypes.isEmpty() /* here we have itemPath == "*" */) 249 { 250 // If no common ancestor, only title attribute is allowed 251 return List.of(Content.ATTRIBUTE_TITLE); 252 } 253 254 if ("*".equals(attributePath)) 255 { 256 return _fieldNamesFromContentTypes(commonContentTypes); 257 } 258 else 259 { 260 return _getFieldsForPath(attributePath, commonContentTypes); 261 } 262 } 263 264 private List<String> _getFieldsForPath(String attributePath, Set<ContentType> commonContentTypes) 265 { 266 String parentPath = StringUtils.substringBeforeLast(attributePath, "/*"); 267 ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes); 268 269 if (lastModelItem instanceof ContentElementDefinition contentElementDefinition) 270 { 271 return _getFieldsForContentElement(contentElementDefinition, parentPath); 272 } 273 else if (lastModelItem instanceof ModelItemGroup modelItemGroup) 274 { 275 return _fieldNamesFromModelItemContainer(modelItemGroup, parentPath + "/"); 276 } 277 else 278 { 279 throw new IllegalArgumentException("Invalid column definition '" + attributePath + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 280 } 281 } 282 283 private List<String> _getFieldsForContentElement(ContentElementDefinition contentElementDefinition, String parentPath) 284 { 285 return Optional.of(contentElementDefinition) 286 .map(ContentElementDefinition::getContentTypeId) 287 .filter(_cTypeEP::hasExtension) 288 .map(_cTypeEP::getExtension) 289 .map(contentType -> _fieldNamesFromModelItemContainer(contentType, parentPath + ModelItem.ITEM_PATH_SEPARATOR)) 290 .orElse(List.of(parentPath + "/" + Content.ATTRIBUTE_TITLE)); 291 } 292 293 private List<String> _fieldNamesFromContentTypes(Collection<ContentType> contentTypes) 294 { 295 List<String> filedNames = new ArrayList<>(); 296 297 for (ContentType contentType : contentTypes) 298 { 299 List<String> fieldNamesFromContentType = _fieldNamesFromModelItemContainer(contentType, StringUtils.EMPTY); 300 filedNames.addAll(fieldNamesFromContentType); 301 } 302 303 return filedNames; 304 } 305 306 private List<String> _fieldNamesFromModelItemContainer(ModelItemContainer modelItemContainer, String prefix) 307 { 308 List<String> fieldNames = new ArrayList<>(); 309 310 for (ModelItem modelItem : modelItemContainer.getModelItems()) 311 { 312 String fieldName = prefix + modelItem.getName(); 313 if (modelItem instanceof CompositeDefinition compositeDefinition) 314 { 315 fieldNames.addAll(_fieldNamesFromModelItemContainer(compositeDefinition, fieldName + ModelItem.ITEM_PATH_SEPARATOR)); 316 } 317 else 318 { 319 fieldNames.add(fieldName); 320 } 321 } 322 323 return fieldNames; 324 } 325 326 List<String> getWildcardSystemColumnPaths(Set<ContentType> commonContentTypes, String path, boolean allowComposite) 327 { 328 if (!isWildcardColumn(path)) 329 { 330 throw new IllegalArgumentException("The given path '" + path + "' does not have the correct syntax."); 331 } 332 333 if ("*".equals(path)) 334 { 335 return _systemPropEP.getDisplayProperties(); 336 } 337 else if (commonContentTypes.isEmpty()) 338 { 339 throw new IllegalArgumentException("The given path '" + path + "' is invalid with an empty common content type (the path cannot be followed)."); 340 } 341 else 342 { 343 String parentPath = StringUtils.substringBeforeLast(path, "/*"); 344 345 if (!ModelHelper.hasModelItem(parentPath, commonContentTypes)) 346 { 347 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 348 } 349 350 ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes); 351 if (lastModelItem instanceof ContentElementDefinition) 352 { 353 String basePath = parentPath + "/"; 354 return _systemPropEP.getDisplayProperties() 355 .stream() 356 .map(prop -> basePath + prop) 357 .collect(Collectors.toList()); 358 } 359 else if (lastModelItem instanceof ModelItemGroup && allowComposite) 360 { 361 return List.of(); 362 } 363 else 364 { 365 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 366 } 367 } 368 } 369 370 private String _getJoinedContentTypeIds(Set<ContentType> contentTypes) 371 { 372 Set<String> commonContentTypeIds = contentTypes.stream() 373 .map(ContentType::getId) 374 .collect(Collectors.toSet()); 375 return StringUtils.join(commonContentTypeIds, ", "); 376 } 377 378 /** 379 * Creates a {@link View} from the given columns 380 * @param contentTypeIds the reference content type identifiers. 381 * @param columns the columns 382 * @param mergeContainers set to <code>true</code> to avoid to create new containers when they are already present in the given view item accessor 383 * @return the created view 384 */ 385 public View createViewFromColumns(Set<String> contentTypeIds, Collection<Column> columns, boolean mergeContainers) 386 { 387 Collection<ContentType> contentTypes = contentTypeIds.stream() 388 .map(_cTypeEP::getExtension) 389 .toList(); 390 391 View resultItems = new View(); 392 for (Column column : columns) 393 { 394 String columnId = column.getId(); 395 396 397 String[] pathSegments = StringUtils.split(columnId, ModelItem.ITEM_PATH_SEPARATOR); 398 399 ViewItemAccessor parent = resultItems; 400 Collection<? extends ModelItemAccessor> realtiveModelItemAccessors = contentTypes; 401 if (pathSegments.length > 1) 402 { 403 // Create or get parent 404 String parentPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1); 405 ModelViewItem parentViewItem = org.ametys.runtime.model.ViewHelper.addViewItem(parentPath, resultItems, false, mergeContainers, contentTypes.toArray(new ContentType[contentTypes.size()])); 406 407 if (parentViewItem instanceof ViewItemAccessor parentViewItemAccessor) 408 { 409 parent = parentViewItemAccessor; 410 realtiveModelItemAccessors = List.of((ModelItemAccessor) parentViewItem.getDefinition()); 411 } 412 else 413 { 414 throw new IllegalArgumentException("Unable to create a column with id '" + columnId + "'. The item at path '" + parentPath + "' is not an accessor."); 415 } 416 } 417 418 ViewItem viewItem = _createViewItem(pathSegments[pathSegments.length - 1], column.getLabel(), realtiveModelItemAccessors); 419 parent.addViewItem(viewItem); 420 } 421 return resultItems; 422 } 423 424 /** 425 * Creates the {@link ViewItem} with the given name. 426 * If the column is a repeater, all its children will be added as columns 427 * @param viewItemName the view item name 428 * @param columnLabel the label of the column, to put to the view item 429 * @param modelItemAccessors the accessors containing the model item with the given name 430 * @return the created view item 431 */ 432 private ViewItem _createViewItem(String viewItemName, Optional<String> columnLabel, Collection<? extends ModelItemAccessor> modelItemAccessors) 433 { 434 ModelItem modelItem = ModelHelper.hasModelItem(viewItemName, modelItemAccessors) 435 ? ModelHelper.getModelItem(viewItemName, modelItemAccessors) 436 : Content.ATTRIBUTE_TITLE.equals(viewItemName) 437 ? _cTypeHelper.getTitleAttributeDefinition() 438 : _systemPropEP.hasExtension(viewItemName) 439 ? _systemPropEP.getExtension(viewItemName) 440 : null; 441 442 if (modelItem == null) 443 { 444 throw new IllegalArgumentException("Unable to create a column with name '" + viewItemName + "'. There is no corresponding model item in model."); 445 } 446 447 // The viewItem is the leaf, create an UI column and add label if needed 448 ModelViewItem viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem); 449 columnLabel.map(I18nizableText::new) 450 .ifPresent(viewItem::setLabel); 451 452 // Add children of repeaters 453 if (modelItem instanceof RepeaterDefinition repeaterDefinition) 454 { 455 for (ModelItem child : repeaterDefinition.getModelItems()) 456 { 457 ViewItem viewItemChild = _createViewItemForGroupChild(child); 458 ((ViewItemAccessor) viewItem).addViewItem(viewItemChild); 459 } 460 } 461 462 return viewItem; 463 } 464 465 /** 466 * Creates the {@link ViewItem} for the given model item, child of a group. 467 * Because we alredy are in a group, composites are allowed and all children of groups (repeater and composites) will be added as columns 468 * @param modelItem the model item 469 * @return the created view item 470 */ 471 @SuppressWarnings("unchecked") 472 private ViewItem _createViewItemForGroupChild(ModelItem modelItem) 473 { 474 ModelViewItem viewItem; 475 if (modelItem instanceof CompositeDefinition compositeDefinition) 476 { 477 viewItem = new ModelViewItemGroup<CompositeDefinition>(); 478 viewItem.setDefinition(compositeDefinition); 479 } 480 else 481 { 482 viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem); 483 } 484 485 // Add children of all groups 486 if (modelItem instanceof ModelItemContainer group) 487 { 488 for (ModelItem child : group.getModelItems()) 489 { 490 ViewItem viewItemChild = _createViewItemForGroupChild(child); 491 ((ViewItemAccessor) viewItem).addViewItem(viewItemChild); 492 } 493 } 494 495 return viewItem; 496 } 497 498 static final class ColumnTransformer 499 { 500 private Set<ContentType> _contentTypeIds; 501 private ColumnHelper _columnHelper; 502 503 ColumnTransformer(Set<ContentType> contentTypeIds, ColumnHelper columnHelper) 504 { 505 _contentTypeIds = contentTypeIds; 506 _columnHelper = columnHelper; 507 } 508 509 Stream<Column> transform(Column column) 510 { 511 String colId = column.getId(); 512 if (_columnHelper.isWildcardColumn(colId)) 513 { 514 return Stream.concat(_attributeCols(colId), _systemCols(colId)); 515 } 516 else if (_columnHelper.isViewReference(colId)) 517 { 518 return _viewRefs(colId); 519 } 520 else 521 { 522 return Stream.of(column); 523 } 524 } 525 526 private Stream<Column> _attributeCols(String colPath) 527 { 528 return _columnHelper.getWildcardAttributeColumnPaths(_contentTypeIds, colPath) 529 .stream() 530 .map(colId -> new Column(colId, null)); 531 } 532 533 private Stream<Column> _systemCols(String colPath) 534 { 535 return _columnHelper.getWildcardSystemColumnPaths(_contentTypeIds, colPath, true) 536 .stream() 537 .map(colId -> new Column(colId, null)); 538 } 539 540 private Stream<Column> _viewRefs(String colPath) 541 { 542 return _columnHelper.getViewReferenceColumnPaths(_contentTypeIds, colPath) 543 .stream() 544 .map(colId -> new Column(colId, null)); 545 } 546 } 547 548 /** 549 * A column and its (optional) label 550 */ 551 public static final class Column 552 { 553 private String _id; 554 private Optional<String> _label; 555 556 /** 557 * Creates a {@link Column} object, wrapping a column id and its (optional) label. 558 * <br>If the provided label is <code>null</code>, then a default label will be applied to the column. 559 * @param columnId The id of the column 560 * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null 561 */ 562 public Column(String columnId, String columnLabel) 563 { 564 _id = columnId; 565 _label = Optional.ofNullable(columnLabel); 566 if (_label.filter(l -> l.contains(",")).isPresent()) 567 { 568 throw new IllegalArgumentException("The label cannot contain a comma."); 569 } 570 } 571 572 /** 573 * Gets the id of the column 574 * @return the id of the column 575 */ 576 public String getId() 577 { 578 return _id; 579 } 580 581 /** 582 * Gets the label of the column 583 * @return the label of the column 584 */ 585 public Optional<String> getLabel() 586 { 587 return _label; 588 } 589 590 @Override 591 public String toString() 592 { 593 return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">"; 594 } 595 596 @Override 597 public int hashCode() 598 { 599 final int prime = 31; 600 int result = 1; 601 result = prime * result + ((_id == null) ? 0 : _id.hashCode()); 602 return result; 603 } 604 605 // only based on the column id 606 @Override 607 public boolean equals(Object obj) 608 { 609 if (this == obj) 610 { 611 return true; 612 } 613 if (obj == null) 614 { 615 return false; 616 } 617 if (getClass() != obj.getClass()) 618 { 619 return false; 620 } 621 Column other = (Column) obj; 622 if (_id == null) 623 { 624 if (other._id != null) 625 { 626 return false; 627 } 628 } 629 else if (!_id.equals(other._id)) 630 { 631 return false; 632 } 633 return true; 634 } 635 } 636}