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