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