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.ContentAttributeDefinition;
035import org.ametys.cms.contenttype.ContentType;
036import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
037import org.ametys.cms.contenttype.ContentTypesHelper;
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 ContentAttributeDefinition))
168        {
169            throw new IllegalArgumentException("The given attribute path '" + path + "' type is not content in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'.");
170        }
171        
172        ContentAttributeDefinition definition = (ContentAttributeDefinition) modelItem;
173        String contentTypeId = definition.getContentTypeId();
174        if (StringUtils.isEmpty(contentTypeId))
175        {
176            throw new IllegalArgumentException("The attribute at path '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "' does not specify any content type.");
177        }
178        
179        ContentType contentType = _cTypeEP.getExtension(contentTypeId);
180        View referencedView = contentType.getView(viewName);
181        if (referencedView == null)
182        {
183            throw new IllegalArgumentException("The view '" + viewName + "' does not exist in content type '" + contentTypeId + "'.");
184        }
185        
186        // Build the list of paths from the referenced view
187        return _getColumnPathsFromViewItemAccessor(referencedView, path + ModelItem.ITEM_PATH_SEPARATOR);
188    }
189    
190    private List<String> _getColumnPathsFromViewItemAccessor(ViewItemAccessor viewItemAccessor, String prefix)
191    {
192        List<String> columnPaths = new ArrayList<>();
193        
194        ViewHelper.visitView(viewItemAccessor, 
195            (element, definition) -> {
196                // simple element
197                String columnPath = prefix + definition.getName();
198                
199                if (element instanceof ViewItemAccessor accessor && !accessor.getViewItems().isEmpty())
200                {
201                    String newPrefix = columnPath + ModelItem.ITEM_PATH_SEPARATOR;
202                    columnPaths.addAll(_getColumnPathsFromViewItemAccessor(accessor, newPrefix));
203                }
204                else
205                {
206                    columnPaths.add(columnPath);
207                }
208            }, 
209            (group, definition) -> {
210                // composite
211                if (!group.getViewItems().isEmpty())
212                {
213                    String newPrefix = prefix + definition.getName() + ModelItem.ITEM_PATH_SEPARATOR;
214                    columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, newPrefix));
215                }
216            }, 
217            (group, definition) -> {
218                // repeater
219                columnPaths.add(prefix + definition.getName());
220            }, 
221            group -> columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, prefix)));
222        
223        return columnPaths;
224    }
225    
226    boolean isWildcardColumn(String path)
227    {
228        return "*".equals(path) || path.endsWith("/*");
229    }
230    
231    List<String> getWildcardAttributeColumnPaths(Set<ContentType> commonContentTypes, String attributePath) throws IllegalArgumentException
232    {
233        if (!isWildcardColumn(attributePath))
234        {
235            throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax.");
236        }
237        
238        if (attributePath.endsWith("/*") && commonContentTypes.isEmpty())
239        {
240            throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed).");
241        }
242        
243        if (commonContentTypes.isEmpty() /* here we have itemPath == "*" */)
244        {
245            // If no common ancestor, only title attribute is allowed
246            return List.of(Content.ATTRIBUTE_TITLE);
247        }
248        
249        if ("*".equals(attributePath))
250        {
251            return _fieldNamesFromContentTypes(commonContentTypes);
252        }
253        else
254        {
255            return _getFieldsForPath(attributePath, commonContentTypes);
256        }
257    }
258    
259    private List<String> _getFieldsForPath(String attributePath, Set<ContentType> commonContentTypes)
260    {
261        String parentPath = StringUtils.substringBeforeLast(attributePath, "/*");
262        ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes);
263        
264        if (lastModelItem instanceof ContentAttributeDefinition contentAttributeDefinition)
265        {
266            return _getFieldsForContentAttribute(contentAttributeDefinition, parentPath);
267        }
268        else if (lastModelItem instanceof ModelItemGroup modelItemGroup)
269        {
270            return _fieldNamesFromModelItemContainer(modelItemGroup, parentPath + "/");
271        }
272        else
273        {
274            throw new IllegalArgumentException("Invalid column definition '" + attributePath + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
275        }
276    }
277    
278    private List<String> _getFieldsForContentAttribute(ContentAttributeDefinition contentAttributeDefinition, String parentPath)
279    {
280        return Optional.of(contentAttributeDefinition)
281              .map(ContentAttributeDefinition::getContentTypeId)
282              .filter(_cTypeEP::hasExtension)
283              .map(_cTypeEP::getExtension)
284              .map(contentType -> _fieldNamesFromModelItemContainer(contentType, parentPath + ModelItem.ITEM_PATH_SEPARATOR))
285              .orElse(List.of(parentPath + "/" + Content.ATTRIBUTE_TITLE));
286    }
287    
288    private List<String> _fieldNamesFromContentTypes(Collection<ContentType> contentTypes)
289    {
290        List<String> filedNames = new ArrayList<>();
291        
292        for (ContentType contentType : contentTypes)
293        {
294            List<String> fieldNamesFromContentType = _fieldNamesFromModelItemContainer(contentType, StringUtils.EMPTY);
295            filedNames.addAll(fieldNamesFromContentType);
296        }
297        
298        return filedNames;
299    }
300    
301    private List<String> _fieldNamesFromModelItemContainer(ModelItemContainer modelItemContainer, String prefix)
302    {
303        List<String> fieldNames = new ArrayList<>();
304        
305        for (ModelItem modelItem : modelItemContainer.getModelItems())
306        {
307            String fieldName = prefix + modelItem.getName();
308            if (modelItem instanceof CompositeDefinition compositeDefinition)
309            {
310                fieldNames.addAll(_fieldNamesFromModelItemContainer(compositeDefinition, fieldName + ModelItem.ITEM_PATH_SEPARATOR));
311            }
312            else
313            {
314                fieldNames.add(fieldName);
315            }
316        }
317        
318        return fieldNames;
319    }
320    
321    List<String> getWildcardSystemColumnPaths(Set<ContentType> commonContentTypes, String path, boolean allowComposite)
322    {
323        if (!isWildcardColumn(path))
324        {
325            throw new IllegalArgumentException("The given path '" + path + "' does not have the correct syntax.");
326        }
327        
328        if ("*".equals(path))
329        {
330            return _systemPropEP.getDisplayProperties();
331        }
332        else if (commonContentTypes.isEmpty())
333        {
334            throw new IllegalArgumentException("The given path '" + path + "' is invalid with an empty common content type (the path cannot be followed).");
335        }
336        else
337        {
338            String parentPath = StringUtils.substringBeforeLast(path, "/*");
339            
340            if (!ModelHelper.hasModelItem(parentPath, commonContentTypes))
341            {
342                throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
343            }
344            
345            ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes);
346            if (lastModelItem instanceof ContentAttributeDefinition)
347            {
348                String basePath = parentPath + "/";
349                return _systemPropEP.getDisplayProperties()
350                        .stream()
351                        .map(prop -> basePath + prop)
352                        .collect(Collectors.toList());
353            }
354            else if (lastModelItem instanceof ModelItemGroup && allowComposite)
355            {
356                return List.of();
357            }
358            else
359            {
360                throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
361            }
362        }
363    }
364
365    private String _getJoinedContentTypeIds(Set<ContentType> contentTypes)
366    {
367        Set<String> commonContentTypeIds = contentTypes.stream()
368                .map(ContentType::getId)
369                .collect(Collectors.toSet());
370        return StringUtils.join(commonContentTypeIds, ", ");
371    }
372    
373    /**
374     * Creates a {@link View} from the given columns
375     * @param contentTypeIds the reference content type identifiers.
376     * @param columns the columns
377     * @param mergeContainers set to <code>true</code> to avoid to create new containers when they are already present in the given view item accessor
378     * @return the created view
379     */
380    public View createViewFromColumns(Set<String> contentTypeIds, Collection<Column> columns, boolean mergeContainers)
381    {
382        View resultItems = new View();
383        for (Column column : columns)
384        {
385            String columnId = column.getId();
386            
387            Collection<ContentType> contentTypes = contentTypeIds.stream()
388                                                                         .map(_cTypeEP::getExtension)
389                                                                         .toList();
390            
391            String[] pathSegments = StringUtils.split(columnId, ModelItem.ITEM_PATH_SEPARATOR);
392            
393            ViewItemAccessor parent = resultItems;
394            Collection<? extends ModelItemAccessor> realtiveModelItemAccessors = contentTypes;
395            if (pathSegments.length > 1)
396            {
397                // Create or get parent
398                String parentPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1);
399                ModelViewItem parentViewItem = org.ametys.runtime.model.ViewHelper.addViewItem(parentPath, resultItems, false, mergeContainers, contentTypes.toArray(new ContentType[contentTypes.size()]));
400                
401                if (parentViewItem instanceof ViewItemAccessor parentViewItemAccessor)
402                {
403                    parent = parentViewItemAccessor;
404                    realtiveModelItemAccessors = List.of((ModelItemAccessor) parentViewItem.getDefinition());
405                }
406                else
407                {
408                    throw new IllegalArgumentException("Unable to create a column with id '" + columnId + "'. The item at path '" + parentPath + "' is not an accessor.");
409                }
410            }
411            
412            ViewItem viewItem = _createViewItem(pathSegments[pathSegments.length - 1], column.getLabel(), realtiveModelItemAccessors);
413            parent.addViewItem(viewItem);
414        }
415        return resultItems;
416    }
417    
418    /**
419     * Creates the {@link ViewItem} with the given name. Retrieves the created view item
420     * @param viewItemName the view item name
421     * @param columnLabel the label of the column, to put to the view item
422     * @param modelItemAccessors the accessors containing the model item with the given name
423     * @return the created view item
424     */
425    private ViewItem _createViewItem(String viewItemName, Optional<String> columnLabel, Collection<? extends ModelItemAccessor> modelItemAccessors)
426    {
427        ModelItem modelItem = ModelHelper.hasModelItem(viewItemName, modelItemAccessors)
428                ? ModelHelper.getModelItem(viewItemName, modelItemAccessors)
429                : Content.ATTRIBUTE_TITLE.equals(viewItemName)
430                    ? _cTypeHelper.getTitleAttributeDefinition()
431                    : _systemPropEP.hasExtension(viewItemName)
432                            ? _systemPropEP.getExtension(viewItemName)
433                            : null;
434        
435        if (modelItem == null)
436        {
437            throw new IllegalArgumentException("Unable to create a column with name '" + viewItemName + "'. There is no corresponding model item in model.");
438        }
439        
440        // The viewItem is the leaf, create an UI column and add label if needed
441        ModelViewItem viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem);
442        columnLabel.map(I18nizableText::new)
443                   .ifPresent(viewItem::setLabel);
444        
445        return viewItem;
446    }
447    
448    static final class ColumnTransformer
449    {
450        private Set<ContentType> _contentTypeIds;
451        private ColumnHelper _columnHelper;
452
453        ColumnTransformer(Set<ContentType> contentTypeIds, ColumnHelper columnHelper)
454        {
455            _contentTypeIds = contentTypeIds;
456            _columnHelper = columnHelper;
457        }
458        
459        Stream<Column> transform(Column column)
460        {
461            String colId = column.getId();
462            if (_columnHelper.isWildcardColumn(colId))
463            {
464                return Stream.concat(_attributeCols(colId), _systemCols(colId));
465            }
466            else if (_columnHelper.isViewReference(colId))
467            {
468                return _viewRefs(colId);
469            }
470            else
471            {
472                return Stream.of(column);
473            }
474        }
475        
476        private Stream<Column> _attributeCols(String colPath)
477        {
478            return _columnHelper.getWildcardAttributeColumnPaths(_contentTypeIds, colPath)
479                    .stream()
480                    .map(colId -> new Column(colId, null));
481        }
482        
483        private Stream<Column> _systemCols(String colPath)
484        {
485            return _columnHelper.getWildcardSystemColumnPaths(_contentTypeIds, colPath, true)
486                    .stream()
487                    .map(colId -> new Column(colId, null));
488        }
489        
490        private Stream<Column> _viewRefs(String colPath)
491        {
492            return _columnHelper.getViewReferenceColumnPaths(_contentTypeIds, colPath)
493                    .stream()
494                    .map(colId -> new Column(colId, null));
495        }
496    }
497    
498    /**
499     * A column and its (optional) label
500     */
501    public static final class Column
502    {
503        private String _id;
504        private Optional<String> _label;
505
506        /**
507         * Creates a {@link Column} object, wrapping a column id and its (optional) label.
508         * <br>If the provided label is <code>null</code>, then a default label will be applied to the column.
509         * @param columnId The id of the column
510         * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null
511         */
512        public Column(String columnId, String columnLabel)
513        {
514            _id = columnId;
515            _label = Optional.ofNullable(columnLabel);
516            if (_label.filter(l -> l.contains(",")).isPresent())
517            {
518                throw new IllegalArgumentException("The label cannot contain a comma.");
519            }
520        }
521        
522        /**
523        * Gets the id of the column
524        * @return the id of the column
525        */
526        public String getId()
527        {
528            return _id;
529        }
530        
531        /**
532        * Gets the label of the column
533        * @return the label of the column 
534        */
535        public Optional<String> getLabel()
536        {
537            return _label;
538        }
539        
540        @Override
541        public String toString()
542        {
543            return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">";
544        }
545
546        @Override
547        public int hashCode()
548        {
549            final int prime = 31;
550            int result = 1;
551            result = prime * result + ((_id == null) ? 0 : _id.hashCode());
552            return result;
553        }
554
555        // only based on the column id
556        @Override
557        public boolean equals(Object obj)
558        {
559            if (this == obj)
560            {
561                return true;
562            }
563            if (obj == null)
564            {
565                return false;
566            }
567            if (getClass() != obj.getClass())
568            {
569                return false;
570            }
571            Column other = (Column) obj;
572            if (_id == null)
573            {
574                if (other._id != null)
575                {
576                    return false;
577                }
578            }
579            else if (!_id.equals(other._id))
580            {
581                return false;
582            }
583            return true;
584        }
585    }
586}