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