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