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