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}