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.Collections; 019import java.util.List; 020import java.util.Optional; 021import java.util.stream.Collectors; 022import java.util.stream.Stream; 023 024import org.apache.avalon.framework.component.Component; 025import org.apache.avalon.framework.service.ServiceException; 026import org.apache.avalon.framework.service.ServiceManager; 027import org.apache.avalon.framework.service.Serviceable; 028import org.apache.commons.lang3.StringUtils; 029 030import org.ametys.cms.contenttype.ContentType; 031import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 032import org.ametys.cms.contenttype.ContentTypesHelper; 033import org.ametys.cms.contenttype.MetadataDefinition; 034import org.ametys.cms.contenttype.MetadataType; 035import org.ametys.cms.contenttype.RepeaterDefinition; 036import org.ametys.cms.contenttype.indexing.IndexingField; 037import org.ametys.cms.contenttype.indexing.IndexingModel; 038import org.ametys.cms.contenttype.indexing.MetadataIndexingField; 039import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 040 041/** 042 * Component providing methods to manipulate {@link Column columns} for search models. 043 */ 044public class ColumnHelper implements Component, Serviceable 045{ 046 /** The Avalon role */ 047 public static final String ROLE = ColumnHelper.class.getName(); 048 049 /** The content type extension point */ 050 protected ContentTypeExtensionPoint _cTypeEP; 051 052 /** The content type helper. */ 053 protected ContentTypesHelper _cTypeHelper; 054 055 /** The system property extension point. */ 056 protected SystemPropertyExtensionPoint _systemPropEP; 057 058 @Override 059 public void service(ServiceManager manager) throws ServiceException 060 { 061 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 062 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 063 _systemPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 064 } 065 066 /** 067 * From a string representing columns, returns the list of column ids with their (optional) labels. 068 * @param columnsStr The columns as a string 069 * @param contentType The common content type 070 * @return the list of column ids with their (optional) labels. 071 */ 072 public List<Column> getColumns(String columnsStr, Optional<String> contentType) 073 { 074 return _getColumns(Stream.of(StringUtils.split(columnsStr, ',')), contentType); 075 } 076 /** 077 * From a list of string representing columns, returns the list of column ids with their (optional) labels. 078 * @param columns The columns 079 * @param contentType The common content type 080 * @return the list of column ids with their (optional) labels. 081 */ 082 public List<Column> getColumns(List<String> columns, Optional<String> contentType) 083 { 084 return _getColumns(columns.stream(), contentType); 085 } 086 087 private List<Column> _getColumns(Stream<String> columns, Optional<String> contentTypeId) 088 { 089 Optional<ContentType> contentType = contentTypeId 090 .map(_cTypeEP::getExtension); 091 092 ColumnTransformer columnTransformer = new ColumnTransformer(contentType, this); 093 094 // in StringUtils.split, adjacent separators are treated as one separator, so col cannot be empty 095 // but it still can be whitespaces only, just ignore them silently 096 return columns.filter(StringUtils::isNotBlank) 097 .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 098 .map(col -> col.split("(?i) AS ", 2)) 099 .map(arr -> 100 { 101 // col is never empty, so arr.length cannot be 0 102 String colId = arr[0].trim().replace('.', '/'); 103 if (arr.length == 2) 104 { 105 return new Column(colId, arr[1].trim()); 106 } 107 else 108 { 109 return new Column(colId, null); 110 } 111 }) 112 .flatMap(columnTransformer::transform) 113 .distinct() 114 .collect(Collectors.toList()); 115 } 116 117 private String _leftTrim(String s) 118 { 119 return s.replaceAll("^\\s+", ""); 120 } 121 122 boolean isWildcardColumn(String path) 123 { 124 return "*".equals(path) || path.endsWith("/*"); 125 } 126 127 List<String> getWildcardMetadataColumnPaths(Optional<ContentType> optCommonContentType, String metadataPath) throws IllegalArgumentException 128 { 129 if (!isWildcardColumn(metadataPath)) 130 { 131 throw new IllegalArgumentException("The given metadataPath '" + metadataPath + "' does not have the correct syntax."); 132 } 133 134 if (metadataPath.endsWith("/*") && !optCommonContentType.isPresent()) 135 { 136 throw new IllegalArgumentException("The given metadataPath '" + metadataPath + "' is invalid with an empty common content type (the path cannot be followed)."); 137 } 138 139 if (!optCommonContentType.isPresent() /* here we have metadataPath == "*" */) 140 { 141 // If no common ancestor, only title metadata is allowed 142 return Collections.singletonList("title"); 143 } 144 ContentType commonContentType = optCommonContentType.get(); 145 146 return "*".equals(metadataPath) 147 ? _fieldNamesFromModel(commonContentType.getIndexingModel(), "") 148 : _getFieldsForPath(metadataPath, commonContentType); 149 } 150 151 private MetadataDefinition _getLastMetadataDefinition(String parentPath, ContentType commonContentType) 152 { 153 List<MetadataDefinition> pathDefinitions = _cTypeHelper.getMetadataDefinitionPath(parentPath, commonContentType); 154 MetadataDefinition lastMetadataDef = pathDefinitions.isEmpty() ? null : pathDefinitions.get(pathDefinitions.size() - 1); 155 return lastMetadataDef; 156 } 157 158 private List<String> _getFieldsForPath(String metadataPath, ContentType commonContentType) 159 { 160 String parentPath = StringUtils.substringBeforeLast(metadataPath, "/*"); 161 MetadataDefinition lastMetadataDef = _getLastMetadataDefinition(parentPath, commonContentType); 162 163 if (lastMetadataDef == null) 164 { 165 throw new IllegalArgumentException("Invalid column definition '" + metadataPath + "' in content type '" + commonContentType.getId() + "'"); 166 } 167 else if (lastMetadataDef.getType() == MetadataType.CONTENT) 168 { 169 return _getFieldsForContentMetadata(lastMetadataDef, parentPath); 170 } 171 else if (lastMetadataDef.getType() == MetadataType.COMPOSITE) 172 { 173 return _getFieldsForCompositeMetadata(lastMetadataDef, parentPath); 174 } 175 else 176 { 177 throw new IllegalArgumentException("Invalid column definition '" + metadataPath + "' in content type '" + commonContentType.getId() + "'"); 178 } 179 } 180 181 private List<String> _getFieldsForContentMetadata(MetadataDefinition lastMetadataDef, String parentPath) 182 { 183 Optional<ContentType> optCType = Optional.of(lastMetadataDef) 184 .map(MetadataDefinition::getContentType) 185 .map(_cTypeEP::getExtension); 186 if (!optCType.isPresent()) 187 { 188 return Collections.singletonList(parentPath + "/title"); 189 } 190 else 191 { 192 ContentType contentMetadataContentType = optCType.get(); 193 IndexingModel indexingModel = contentMetadataContentType.getIndexingModel(); 194 return _fieldNamesFromModel(indexingModel, parentPath + "/"); 195 } 196 } 197 198 private List<String> _getFieldsForCompositeMetadata(MetadataDefinition lastMetadataDef, String parentPath) 199 { 200 final String prefix = parentPath + "/"; 201 return lastMetadataDef.getMetadataNames() 202 .stream() 203 .map(lastMetadataDef::getMetadataDefinition) 204 .filter(this::_filterComposite) 205 .map(MetadataDefinition::getName) 206 .map(prefix::concat) 207 .collect(Collectors.toList()); 208 } 209 210 private List<String> _fieldNamesFromModel(IndexingModel indexingModel, String prefix) 211 { 212 return indexingModel.getFields() 213 .stream() 214 // Get only first-level metadata (ignore composites) 215 .filter(this::_filterComposite) 216 // Only metadata fields are displayable 217 .filter(MetadataIndexingField.class::isInstance) 218 .map(IndexingField::getName) 219 .map(prefix::concat) 220 .collect(Collectors.toList()); 221 } 222 223 private boolean _filterComposite(IndexingField field) 224 { 225 if (field instanceof MetadataIndexingField) 226 { 227 MetadataDefinition metaDef = ((MetadataIndexingField) field).getMetadataDefinition(); 228 return _filterComposite(metaDef); 229 } 230 return true; 231 } 232 233 private boolean _filterComposite(MetadataDefinition metaDef) 234 { 235 return metaDef != null && (metaDef.getType() != MetadataType.COMPOSITE || metaDef instanceof RepeaterDefinition); 236 } 237 238 List<String> getWildcardSystemColumnPaths(Optional<ContentType> optCommonContentType, String path, boolean allowComposite) 239 { 240 if (!isWildcardColumn(path)) 241 { 242 throw new IllegalArgumentException("The given path '" + path + "' does not have the correct syntax."); 243 } 244 245 if ("*".equals(path)) 246 { 247 return _systemPropEP.getDisplayProperties(); 248 } 249 else if (!optCommonContentType.isPresent()) 250 { 251 throw new IllegalArgumentException("The given path '" + path + "' is invalid with an empty common content type (the path cannot be followed)."); 252 } 253 else 254 { 255 ContentType commonContentType = optCommonContentType.get(); 256 String parentPath = StringUtils.substringBeforeLast(path, "/*"); 257 MetadataDefinition lastMetadataDef = _getLastMetadataDefinition(parentPath, commonContentType); 258 if (lastMetadataDef == null) 259 { 260 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content type '" + commonContentType.getId() + "'"); 261 } 262 else if (lastMetadataDef.getType() == MetadataType.CONTENT) 263 { 264 String basePath = parentPath + "/"; 265 return _systemPropEP.getDisplayProperties() 266 .stream() 267 .map(prop -> basePath + prop) 268 .collect(Collectors.toList()); 269 } 270 else if (lastMetadataDef.getType() == MetadataType.COMPOSITE && allowComposite) 271 { 272 return Collections.emptyList(); 273 } 274 else 275 { 276 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content type '" + commonContentType.getId() + "'"); 277 } 278 } 279 } 280 281 static final class ColumnTransformer 282 { 283 private Optional<ContentType> _contentType; 284 private ColumnHelper _columnHelper; 285 286 ColumnTransformer(Optional<ContentType> contentType, ColumnHelper columnHelper) 287 { 288 _contentType = contentType; 289 _columnHelper = columnHelper; 290 } 291 292 Stream<Column> transform(Column column) 293 { 294 String colId = column.getId(); 295 if (_columnHelper.isWildcardColumn(colId)) 296 { 297 return Stream.concat(_metadataCols(colId), _systemCols(colId)); 298 } 299 else 300 { 301 return Stream.of(column); 302 } 303 } 304 305 private Stream<Column> _metadataCols(String colPath) 306 { 307 return _columnHelper.getWildcardMetadataColumnPaths(_contentType, colPath) 308 .stream() 309 .map(colId -> new Column(colId, null)); 310 } 311 312 private Stream<Column> _systemCols(String colPath) 313 { 314 return _columnHelper.getWildcardSystemColumnPaths(_contentType, colPath, true) 315 .stream() 316 .map(colId -> new Column(colId, null)); 317 } 318 } 319 320 /** 321 * A column and its (optional) label 322 */ 323 public static final class Column 324 { 325 private String _id; 326 private Optional<String> _label; 327 328 /** 329 * Creates a {@link Column} object, wrapping a column id and its (optional) label. 330 * <br>If the provided label is <code>null</code>, then a default label will be applied to the column. 331 * @param columnId The id of the column 332 * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null 333 */ 334 public Column(String columnId, String columnLabel) 335 { 336 _id = columnId; 337 _label = Optional.ofNullable(columnLabel); 338 if (_label.filter(l -> l.contains(",")).isPresent()) 339 { 340 throw new IllegalArgumentException("The label cannot contain a comma."); 341 } 342 } 343 344 /** 345 * Gets the id of the column 346 * @return the id of the column 347 */ 348 public String getId() 349 { 350 return _id; 351 } 352 353 /** 354 * Gets the label of the column 355 * @return the label of the column 356 */ 357 public Optional<String> getLabel() 358 { 359 return _label; 360 } 361 362 @Override 363 public String toString() 364 { 365 return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">"; 366 } 367 368 @Override 369 public int hashCode() 370 { 371 final int prime = 31; 372 int result = 1; 373 result = prime * result + ((_id == null) ? 0 : _id.hashCode()); 374 return result; 375 } 376 377 // only based on the column id 378 @Override 379 public boolean equals(Object obj) 380 { 381 if (this == obj) 382 { 383 return true; 384 } 385 if (obj == null) 386 { 387 return false; 388 } 389 if (getClass() != obj.getClass()) 390 { 391 return false; 392 } 393 Column other = (Column) obj; 394 if (_id == null) 395 { 396 if (other._id != null) 397 { 398 return false; 399 } 400 } 401 else if (!_id.equals(other._id)) 402 { 403 return false; 404 } 405 return true; 406 } 407 } 408}