001/*
002 *  Copyright 2017 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.impl;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashSet;
022import java.util.Iterator;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Set;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.configuration.Configurable;
031import org.apache.avalon.framework.configuration.Configuration;
032import org.apache.avalon.framework.configuration.ConfigurationException;
033import org.apache.avalon.framework.configuration.DefaultConfiguration;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.avalon.framework.service.Serviceable;
040import org.apache.cocoon.components.LifecycleHelper;
041import org.apache.cocoon.util.log.SLF4JLoggerAdapter;
042import org.apache.commons.lang3.StringUtils;
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045
046import org.ametys.cms.contenttype.ContentType;
047import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
048import org.ametys.cms.contenttype.ContentTypesHelper;
049import org.ametys.cms.contenttype.MetadataDefinition;
050import org.ametys.cms.data.type.ModelItemTypeConstants;
051import org.ametys.cms.repository.Content;
052import org.ametys.cms.search.SearchField;
053import org.ametys.cms.search.content.ContentSearchHelper;
054import org.ametys.cms.search.model.MetadataResultField;
055import org.ametys.cms.search.ui.model.SearchUIColumn;
056import org.ametys.plugins.repository.model.CompositeDefinition;
057import org.ametys.plugins.repository.model.RepeaterDefinition;
058import org.ametys.runtime.model.ElementDefinition;
059import org.ametys.runtime.model.ModelHelper;
060import org.ametys.runtime.model.ModelItem;
061import org.ametys.runtime.model.ModelItemContainer;
062import org.ametys.runtime.model.exception.UndefinedItemPathException;
063import org.ametys.runtime.model.type.ModelItemType;
064
065/**
066 * Default implementation of a search ui column for a content's attribute
067 */
068public class MetadataSearchUIColumn extends AbstractSearchUIColumn implements MetadataResultField, Serviceable, Configurable, Contextualizable
069{
070    /** The service manager */
071    protected ServiceManager _smanager;
072    /** The context */
073    protected Context _context;
074    
075    /** The content type extension point. */
076    protected ContentTypeExtensionPoint _cTypeEP;
077    
078    /** The content type helper. */
079    protected ContentTypesHelper _cTypeHelper;
080    
081    /** The search helper. */
082    protected ContentSearchHelper _searchHelper;
083    
084    /** The full attribute path. */
085    protected String _fullAttributePath;
086    
087    /** True if the attribute is joined, false otherwise. */
088    protected boolean _joinedAttribute;
089
090    /** The types of the content on which this attribute column applies */
091    protected Collection<String> _contentTypes;
092
093    /** The attribute represented by the column */
094    protected ModelItem _attribute;
095    
096    private boolean _isTypeContentWithMultilingualTitle;
097
098    
099    @Override
100    public void service(ServiceManager manager) throws ServiceException
101    {
102        _smanager = manager;
103        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
104        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
105        _searchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE);
106    }
107    
108    public void contextualize(Context context) throws ContextException
109    {
110        _context = context;
111    }
112    
113    @Override
114    public void configure(Configuration configuration) throws ConfigurationException
115    {
116        // The full path can contain "join" paths.
117        _fullAttributePath = configuration.getChild("metadata").getAttribute("path");
118        
119        Set<String> baseContentTypeIds = new HashSet<>();
120        for (Configuration cTypeConf : configuration.getChild("contentTypes").getChildren("baseType"))
121        {
122            baseContentTypeIds.add(cTypeConf.getAttribute("id"));
123        }
124        
125        List<ModelItem> modelItems = _configureModelItems(baseContentTypeIds, _fullAttributePath);
126        
127        // Compute "join" and "multiple" status.
128        boolean joinedAttribute = false;
129        boolean multiple = false;
130        boolean multiLevelMultiple = false;
131        
132        Iterator<ModelItem> itemsIterator = modelItems.iterator();
133        while (itemsIterator.hasNext())
134        {
135            ModelItem modelItem = itemsIterator.next();
136            ModelItemType type = modelItem.getType();
137            // The column has multiple values if the full path contains a multiple attribute or a repeater.
138            if (_isAttributeMultiple(modelItem))
139            {
140                multiple = true;
141                if (itemsIterator.hasNext())
142                {
143                    multiLevelMultiple = true;
144                }
145            }
146            // The column represents a "joined" value if it has a content attribute (except if it's the last one).
147            if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(type.getId()) && itemsIterator.hasNext())
148            {
149                joinedAttribute = true;
150            }
151        }
152        
153        setJoinedAttribute(joinedAttribute);
154        setMultiple(multiple);
155        
156        _attribute = modelItems.get(modelItems.size() - 1);
157        
158        MetadataDefinition metadataDefinition = _getMetadataDefinition(baseContentTypeIds, _fullAttributePath);
159        setId(_fullAttributePath);
160        setType(metadataDefinition.getType());
161        setLabel(_configureI18nizableText(configuration.getChild("label", false), _attribute.getLabel()));
162        setDescription(_configureI18nizableText(configuration.getChild("description", false), _attribute.getDescription()));
163        setWidth(configuration.getChild("width").getValueAsInteger(200));
164        setHidden(configuration.getChild("hidden").getValueAsBoolean(false));
165        
166        setValidator(metadataDefinition.getValidator());
167        setEnumerator(metadataDefinition.getEnumerator());
168        setWidget(metadataDefinition.getWidget());
169        setWidgetParameters(metadataDefinition.getWidgetParameters());
170        setContentTypeId(metadataDefinition.getContentType());
171        
172        configureRenderer(configuration.getChild("renderer").getValue(null), _attribute);
173        configureConverter(configuration.getChild("converter").getValue(null), _attribute);
174        
175        boolean editable = _isEditable(configuration, _attribute, multiLevelMultiple);
176        setEditable(editable);
177        
178        boolean sortable = _isSortable(configuration, _attribute, multiple);
179        setSortable(sortable);
180        
181        if (sortable)
182        {
183            setDefaultSorter(configuration.getChild("default-sorter").getValue(null));
184        }
185        
186        List<String> contentTypeIds = new ArrayList<>();
187        ModelItem firstModelItem = modelItems.get(0);
188        if (firstModelItem.getModel() != null)
189        {
190            // The title attribute has no model
191            contentTypeIds.add(firstModelItem.getModel().getId());
192        }
193        setContentTypes(contentTypeIds);
194        
195        setTypeContentWithMultilingualTitle(_searchHelper.isTitleMultilingual(_attribute));
196    }
197    
198    /**
199     * Computed sortable status of this column from its attribute definition and configuration
200     * @param configuration the column's configuration
201     * @param attribute the attribute definition
202     * @param multiplePath if path is multiple
203     * @return true if this column is sortable
204     */
205    protected boolean _isSortable(Configuration configuration, ModelItem attribute, boolean multiplePath)
206    {
207        boolean sortable = configuration.getChild("sortable").getValueAsBoolean(true);
208        boolean allowSortOnMultipleJoin = configuration.getChild("allow-sort-on-multiple-join").getValueAsBoolean(false);
209        
210        return sortable
211                && (allowSortOnMultipleJoin && !_isAttributeMultiple(attribute) // if path is not multiple, but an intermediate in the path is, it is OK => consider as sortable
212                        || !allowSortOnMultipleJoin && !multiplePath)    // if path is multiple => do not consider as sortable
213                && _isSortableMetadata(attribute);
214        
215    }
216    
217    /**
218     * Computed editable status of this column from its attribute definition and configuration
219     * @param configuration the column's configuration
220     * @param attribute the attribute definition
221     * @param multiLevelMultiple if path contains a multiple attrbute or a repeater.
222     * @return true if this column is editable
223     */
224    protected boolean _isEditable(Configuration configuration, ModelItem attribute, boolean multiLevelMultiple)
225    {
226        boolean editable = configuration.getChild("editable").getValueAsBoolean(true);
227        
228        return editable && !multiLevelMultiple && isEditionAllowed(attribute);
229    }
230
231    private List<ModelItem> _configureModelItems(Set<String> contentTypeIds, String attributePath) throws ConfigurationException
232    {
233        List<ModelItem> modelItems;
234        
235        if (!contentTypeIds.isEmpty())
236        {
237            try
238            {
239                Set<ContentType> contentTypes = contentTypeIds.stream()
240                        .map(_cTypeEP::getExtension)
241                        .collect(Collectors.toSet());
242                modelItems = ModelHelper.getAllModelItemsInPath(attributePath, contentTypes);
243            }
244            catch (UndefinedItemPathException e)
245            {
246                throw new ConfigurationException("Unknown attribute '" + attributePath + "' in content types '" + StringUtils.join(contentTypeIds, ", ") + "'", e);
247            }
248        }
249        else if (Content.ATTRIBUTE_TITLE.equals(attributePath))
250        {
251            // Only the title attribute is allowed if the base content type is null.
252            modelItems = Collections.singletonList(_cTypeHelper.getTitleAttributeDefinition());
253        }
254        else
255        {
256            throw new ConfigurationException("The attribute '" + attributePath + "' is forbidden when no content type is specified: only '" + Content.ATTRIBUTE_TITLE + "' can be used.");
257        }
258        
259        return modelItems;
260    }
261    
262    private MetadataDefinition _getMetadataDefinition(Set<String> contentTypeIds, String metadataPath)
263    {
264        if (!contentTypeIds.isEmpty())
265        {
266            String[] cTypes = contentTypeIds.toArray(String[]::new);
267            return _cTypeHelper.getMetadataDefinition(metadataPath, cTypes, new String[0]);
268        }
269        else
270        {
271            return ContentTypesHelper.getTitleMetadataDefinition();
272        }
273    }
274    
275    /**
276     * Determines if the inline edition is allowed
277     * @param attribute The attribute definition
278     * @return true if the attribute is editable
279     */
280    protected boolean isEditionAllowed(ModelItem attribute)
281    {
282        if (isJoinedAttribute())
283        {
284            // attribute is not editable if it is on another content
285            return false;
286        }
287        
288        if (ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(attribute.getType().getId()))
289        {
290            // richtext are never editable inline
291            return false;
292        }
293        
294        if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(attribute.getType().getId()))
295        {
296            // disallow edition for repeater which contains richtext
297            return !ModelHelper.hasModelItemOfType((ModelItemContainer) attribute, ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID);
298        }
299        
300        return true;
301    }
302    
303    /**
304     * Configure the column renderer.
305     * @param renderer A specific renderer. If null, it will be deduced from the attribute definition.
306     * @param attribute The attribute definition.
307     */
308    protected void configureRenderer(String renderer, ModelItem attribute)
309    {
310        String typeId = attribute.getType().getId();
311        if (renderer != null)
312        {
313            setRenderer(renderer);
314        }
315        else if (Content.ATTRIBUTE_TITLE.equals(getFieldPath()) && org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(typeId))
316        {
317            setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderTitle");
318        }
319        else if (Content.ATTRIBUTE_TITLE.equals(getFieldPath()) && ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(typeId))
320        {
321            setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderMultilingualTitle");
322        }
323        else if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(typeId))
324        {
325            setRenderer("Ametys.plugins.cms.search.SearchGridHelper.renderRepeater");
326        }
327    }
328    
329    /**
330     * Configure the column converter.
331     * @param converter A specific converter. If null, it will be deduced from the metadata definition.
332     * @param attribute The attribute definition.
333     */
334    protected void configureConverter(String converter, ModelItem attribute)
335    {
336        String typeId = attribute.getType().getId();
337        if (converter != null)
338        {
339            setConverter(converter);
340        }
341        else if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(typeId))
342        {
343            setConverter("Ametys.plugins.cms.search.SearchGridHelper.convertContent");
344        }
345        else if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(typeId))
346        {
347            setConverter("Ametys.plugins.cms.search.SearchGridHelper.convertRepeater");
348        }
349    }
350    
351    private boolean _isAttributeMultiple(ModelItem attribute)
352    {
353        return attribute instanceof ElementDefinition && ((ElementDefinition) attribute).isMultiple()
354                || attribute instanceof RepeaterDefinition;
355    }
356    
357    /**
358     * Set the attribute path
359     * @param attributePath the path to attribute
360     */
361    public void setFieldPath(String attributePath)
362    {
363        _fullAttributePath = attributePath;
364    }
365    
366    /**
367     * Get the path of attribute (separated by '/')
368     * @return the path of attribute
369     */
370    public String getFieldPath()
371    {
372        return _fullAttributePath;
373    }
374    
375    /**
376     * Get the content types of the contents on which this attribute column applies
377     * @return the content types
378     */
379    public Collection<String> getContentTypes()
380    {
381        return _contentTypes;
382    }
383    
384    /**
385     * Set the content types of the contents on which this attribute column applies
386     * @param contentTypes the content types
387     */
388    public void setContentTypes(Collection<String> contentTypes)
389    {
390        _contentTypes = contentTypes;
391    }
392    
393    /**
394     * Determines if this column is a path to a joined attribute
395     * @return true if is a joined attribute
396     */
397    public boolean isJoinedAttribute()
398    {
399        return _joinedAttribute;
400    }
401    
402    /**
403     * Set if this column is a path to a joined attribute
404     * @param joinedAttribute true if is a joined attribute
405     */
406    public void setJoinedAttribute(boolean joinedAttribute)
407    {
408        _joinedAttribute = joinedAttribute;
409    }
410    
411    /**
412     * Determines if this column represents a CONTENT attribute with a multilingual title
413     * @return true if this column represents a CONTENT attribute with a multilingual title
414     */
415    public boolean isTypeContentWithMultilingualTitle()
416    {
417        return _isTypeContentWithMultilingualTitle;
418    }
419    
420    /**
421     * Set if this column represents a CONTENT attribute with a multilingual title 
422     * @param multilingual true if this column represents a CONTENT attribute with a multilingual title
423     */
424    public void setTypeContentWithMultilingualTitle(boolean multilingual)
425    {
426        _isTypeContentWithMultilingualTitle = multilingual;
427    }
428    
429    @Override
430    public Object getValue(Content content, Locale defaultLocale)
431    {
432        return _searchHelper.getAttributeValue(content, getFieldPath(), _attribute, defaultLocale, false);
433    }
434    
435    @Override
436    public Object getFullValue(Content content, Locale defaultLocale)
437    {
438        return _searchHelper.getAttributeValue(content, getFieldPath(), _attribute, defaultLocale, true);
439    }
440    
441    @Override
442    public SearchField getSearchField()
443    {
444        if (isJoinedAttribute())
445        {
446            return _searchHelper.getSearchField(getContentTypes(), getFieldPath()).orElse(null);
447        }
448        else
449        {
450            return _searchHelper.getMetadataSearchField(getFieldPath(), getType(), isTypeContentWithMultilingualTitle());
451        }
452    }
453    
454    /**
455     * Determines if the column is sortable according its attribute definition
456     * @param attribute the attribute definition
457     * @return true if type is sortable
458     */
459    @SuppressWarnings("static-access")
460    protected boolean _isSortableMetadata(ModelItem attribute)
461    {
462        switch (attribute.getType().getId())
463        { 
464            case ModelItemTypeConstants.STRING_TYPE_ID:
465            case ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID:
466            case ModelItemTypeConstants.LONG_TYPE_ID:
467            case ModelItemTypeConstants.DATE_TYPE_ID:
468            case ModelItemTypeConstants.DATETIME_TYPE_ID:
469            case ModelItemTypeConstants.BOOLEAN_TYPE_ID:
470            case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID:
471            case ModelItemTypeConstants.DOUBLE_TYPE_ID:
472            case ModelItemTypeConstants.USER_ELEMENT_TYPE_ID:
473            case ModelItemTypeConstants.REFERENCE_ELEMENT_TYPE_ID:
474                return true;
475            case ModelItemTypeConstants.COMPOSITE_TYPE_ID:
476            case ModelItemTypeConstants.REPEATER_TYPE_ID:
477            case ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID:
478            case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID:
479            case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID:
480            default:
481                return false;
482        }
483    }
484
485    public Map<String, SearchUIColumn> getSubColumns()
486    {
487        return _attribute instanceof ModelItemContainer ? _getSubColumns((ModelItemContainer) _attribute, StringUtils.EMPTY) : null;
488    }
489    
490    private Map<String, SearchUIColumn> _getSubColumns(ModelItemContainer modelItemContainer, String attributePathPrefix)
491    {
492        Map<String, SearchUIColumn> subColumns = new LinkedHashMap<>();
493        
494        for (ModelItem modelItem : modelItemContainer.getModelItems())
495        {
496            String modelItemPath = StringUtils.isNotEmpty(attributePathPrefix) ? attributePathPrefix  + ModelItem.ITEM_PATH_SEPARATOR + modelItem.getName() : modelItem.getName();
497            
498            if (modelItem instanceof CompositeDefinition)
499            {
500                // CMS-10740 - explode composite to have all its children at first level
501                subColumns.putAll(_getSubColumns((CompositeDefinition) modelItem, modelItemPath));
502            }
503            else
504            {
505                MetadataSearchUIColumn metadataSearchUIColumn = new MetadataSearchUIColumn();
506                Logger logger = LoggerFactory.getLogger(MetadataSearchUIColumn.class);
507                try
508                {
509                    Configuration columnConfig = _getSubColumnConfiguration(modelItem);
510                    LifecycleHelper.setupComponent(metadataSearchUIColumn, new SLF4JLoggerAdapter(logger), _context, _smanager, columnConfig);
511                    
512                    metadataSearchUIColumn.setId(modelItemPath); // Force field path to relative field path
513                    metadataSearchUIColumn.setFieldPath(modelItemPath); // Force field path to relative field path
514                    metadataSearchUIColumn.setEditable(isEditionAllowed(modelItem)); // Edition only depends on final definition
515                    metadataSearchUIColumn.setMultiple(modelItem instanceof ElementDefinition && ((ElementDefinition) modelItem).isMultiple()); // Multiple only depends on final definition
516                }
517                catch (Exception e)
518                {
519                    throw new RuntimeException("Unable to initialize search ui column for attribute '" + modelItem.getPath() + "'.", e);
520                }
521                finally 
522                {
523                    LifecycleHelper.dispose(metadataSearchUIColumn);
524                }
525            
526                subColumns.put(modelItemPath, metadataSearchUIColumn);
527            }
528        }
529        
530        return subColumns;
531    }
532    
533    private Configuration _getSubColumnConfiguration(ModelItem modelItem)
534    {
535        DefaultConfiguration columnConfig = new DefaultConfiguration("column");
536        
537        DefaultConfiguration metaConfig = new DefaultConfiguration("metadata");
538        metaConfig.setAttribute("path", modelItem.getPath());
539        columnConfig.addChild(metaConfig);
540        
541        DefaultConfiguration cTypesConfig = new DefaultConfiguration("contentTypes");
542        
543        DefaultConfiguration baseCTypeConfig = new DefaultConfiguration("baseType");
544        baseCTypeConfig.setAttribute("id", modelItem.getModel().getId());
545        cTypesConfig.addChild(baseCTypeConfig);
546        
547        for (String cTypeId : getContentTypes())
548        {
549            DefaultConfiguration cTypeConfig = new DefaultConfiguration("type");
550            cTypeConfig.setAttribute("id", cTypeId);
551            cTypesConfig.addChild(cTypeConfig);
552        }
553        
554        columnConfig.addChild(cTypesConfig);
555        
556        return columnConfig;
557    }
558
559}