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            String[] pathSegments = StringUtils.split(columnId, ModelItem.ITEM_PATH_SEPARATOR);
397            
398            ViewItemAccessor parent = resultItems;
399            Collection<? extends ModelItemAccessor> relativeModelItemAccessors = contentTypes;
400            if (pathSegments.length > 1)
401            {
402                // Create or get parent
403                String parentPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1);
404                ModelViewItem parentViewItem = org.ametys.runtime.model.ViewHelper.addViewItem(parentPath, resultItems, false, mergeContainers, contentTypes.toArray(new ContentType[contentTypes.size()]));
405                
406                if (parentViewItem instanceof ViewItemAccessor parentViewItemAccessor)
407                {
408                    parent = parentViewItemAccessor;
409                    relativeModelItemAccessors = List.of((ModelItemAccessor) parentViewItem.getDefinition());
410                }
411                else
412                {
413                    throw new IllegalArgumentException("Unable to create a column with id '" + columnId + "'. The item at path '" + parentPath + "' is not an accessor.");
414                }
415            }
416            
417            ViewItem viewItem = _createViewItem(pathSegments[pathSegments.length - 1], column.getLabel(), relativeModelItemAccessors);
418            parent.addViewItem(viewItem);
419        }
420        return resultItems;
421    }
422    
423    /**
424     * Creates the {@link ViewItem} with the given name.
425     * If the column is a repeater, all its children will be added as columns
426     * @param viewItemName the view item name
427     * @param columnLabel the label of the column, to put to the view item
428     * @param modelItemAccessors the accessors containing the model item with the given name
429     * @return the created view item
430     */
431    private ViewItem _createViewItem(String viewItemName, Optional<String> columnLabel, Collection<? extends ModelItemAccessor> modelItemAccessors)
432    {
433        ModelItem modelItem = ModelHelper.hasModelItem(viewItemName, modelItemAccessors)
434                ? ModelHelper.getModelItem(viewItemName, modelItemAccessors)
435                : Content.ATTRIBUTE_TITLE.equals(viewItemName)
436                    ? _cTypeHelper.getTitleAttributeDefinition()
437                    : _systemPropEP.hasExtension(viewItemName)
438                            ? _systemPropEP.getExtension(viewItemName)
439                            : null;
440        
441        if (modelItem == null)
442        {
443            throw new IllegalArgumentException("Unable to create a column with name '" + viewItemName + "'. There is no corresponding model item in model.");
444        }
445        
446        // The viewItem is the leaf, create an UI column and add label if needed
447        ModelViewItem viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem);
448        columnLabel.map(I18nizableText::new)
449                   .ifPresent(viewItem::setLabel);
450        
451        // Add children of repeaters
452        if (modelItem instanceof RepeaterDefinition repeaterDefinition)
453        {
454            for (ModelItem child : repeaterDefinition.getModelItems())
455            {
456                ViewItem viewItemChild = _createViewItemForGroupChild(child);
457                ((ViewItemAccessor) viewItem).addViewItem(viewItemChild);
458            }
459        }
460        
461        return viewItem;
462    }
463    
464    /**
465     * Creates the {@link ViewItem} for the given model item, child of a group.
466     * Because we alredy are in a group, composites are allowed and all children of groups (repeater and composites) will be added as columns
467     * @param modelItem the model item
468     * @return the created view item
469     */
470    @SuppressWarnings("unchecked")
471    private ViewItem _createViewItemForGroupChild(ModelItem modelItem)
472    {
473        ModelViewItem viewItem;
474        if (modelItem instanceof CompositeDefinition compositeDefinition)
475        {
476            viewItem = new ModelViewItemGroup<CompositeDefinition>();
477            viewItem.setDefinition(compositeDefinition);
478        }
479        else
480        {
481            viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem);
482        }
483        
484        // Add children of all groups
485        if (modelItem instanceof ModelItemContainer group)
486        {
487            for (ModelItem child : group.getModelItems())
488            {
489                ViewItem viewItemChild = _createViewItemForGroupChild(child);
490                ((ViewItemAccessor) viewItem).addViewItem(viewItemChild);
491            }
492        }
493        
494        return viewItem;
495    }
496    
497    static final class ColumnTransformer
498    {
499        private Set<ContentType> _contentTypeIds;
500        private ColumnHelper _columnHelper;
501
502        ColumnTransformer(Set<ContentType> contentTypeIds, ColumnHelper columnHelper)
503        {
504            _contentTypeIds = contentTypeIds;
505            _columnHelper = columnHelper;
506        }
507        
508        Stream<Column> transform(Column column)
509        {
510            String colId = column.getId();
511            if (_columnHelper.isWildcardColumn(colId))
512            {
513                return Stream.concat(_attributeCols(colId), _systemCols(colId));
514            }
515            else if (_columnHelper.isViewReference(colId))
516            {
517                return _viewRefs(colId);
518            }
519            else
520            {
521                return Stream.of(column);
522            }
523        }
524        
525        private Stream<Column> _attributeCols(String colPath)
526        {
527            return _columnHelper.getWildcardAttributeColumnPaths(_contentTypeIds, colPath)
528                    .stream()
529                    .map(colId -> new Column(colId, null));
530        }
531        
532        private Stream<Column> _systemCols(String colPath)
533        {
534            return _columnHelper.getWildcardSystemColumnPaths(_contentTypeIds, colPath, true)
535                    .stream()
536                    .map(colId -> new Column(colId, null));
537        }
538        
539        private Stream<Column> _viewRefs(String colPath)
540        {
541            return _columnHelper.getViewReferenceColumnPaths(_contentTypeIds, colPath)
542                    .stream()
543                    .map(colId -> new Column(colId, null));
544        }
545    }
546    
547    /**
548     * A column and its (optional) label
549     */
550    public static final class Column
551    {
552        private String _id;
553        private Optional<String> _label;
554
555        /**
556         * Creates a {@link Column} object, wrapping a column id and its (optional) label.
557         * <br>If the provided label is <code>null</code>, then a default label will be applied to the column.
558         * @param columnId The id of the column
559         * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null
560         */
561        public Column(String columnId, String columnLabel)
562        {
563            _id = columnId;
564            _label = Optional.ofNullable(columnLabel);
565            if (_label.filter(l -> l.contains(",")).isPresent())
566            {
567                throw new IllegalArgumentException("The label cannot contain a comma.");
568            }
569        }
570        
571        /**
572        * Gets the id of the column
573        * @return the id of the column
574        */
575        public String getId()
576        {
577            return _id;
578        }
579        
580        /**
581        * Gets the label of the column
582        * @return the label of the column
583        */
584        public Optional<String> getLabel()
585        {
586            return _label;
587        }
588        
589        @Override
590        public String toString()
591        {
592            return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">";
593        }
594
595        @Override
596        public int hashCode()
597        {
598            final int prime = 31;
599            int result = 1;
600            result = prime * result + ((_id == null) ? 0 : _id.hashCode());
601            return result;
602        }
603
604        // only based on the column id
605        @Override
606        public boolean equals(Object obj)
607        {
608            if (this == obj)
609            {
610                return true;
611            }
612            if (obj == null)
613            {
614                return false;
615            }
616            if (getClass() != obj.getClass())
617            {
618                return false;
619            }
620            Column other = (Column) obj;
621            if (_id == null)
622            {
623                if (other._id != null)
624                {
625                    return false;
626                }
627            }
628            else if (!_id.equals(other._id))
629            {
630                return false;
631            }
632            return true;
633        }
634    }
635}