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}