001/*
002 *  Copyright 2013 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.Arrays;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.Optional;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.configuration.Configurable;
028import org.apache.avalon.framework.configuration.Configuration;
029import org.apache.avalon.framework.configuration.ConfigurationException;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.commons.lang3.ArrayUtils;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.cms.content.ContentHelper;
037import org.ametys.cms.contenttype.ContentConstants;
038import org.ametys.cms.contenttype.ContentType;
039import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
040import org.ametys.cms.contenttype.ContentTypesHelper;
041import org.ametys.cms.contenttype.MetadataDefinition;
042import org.ametys.cms.contenttype.MetadataType;
043import org.ametys.cms.contenttype.RepeaterDefinition;
044import org.ametys.cms.contenttype.indexing.IndexingField;
045import org.ametys.cms.contenttype.indexing.IndexingModel;
046import org.ametys.cms.contenttype.indexing.MetadataIndexingField;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.search.SearchField;
049import org.ametys.cms.search.ui.model.SearchUICriterion;
050import org.ametys.cms.search.ui.model.SearchUIModel;
051import org.ametys.core.user.User;
052import org.ametys.core.user.UserIdentity;
053import org.ametys.core.user.UserManager;
054import org.ametys.core.user.population.UserPopulationDAO;
055import org.ametys.plugins.core.ui.util.ConfigurationHelper;
056import org.ametys.plugins.repository.AmetysObjectResolver;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.parameter.DefaultValidator;
059import org.ametys.runtime.parameter.Enumerator;
060import org.ametys.runtime.parameter.Parameter;
061import org.ametys.runtime.parameter.Validator;
062import org.ametys.runtime.plugin.component.ThreadSafeComponentManager;
063
064/**
065 * This class represents a search criterion of a {@link SearchUIModel}.
066 */
067public abstract class AbstractSearchUICriterion extends Parameter<MetadataType> implements SearchUICriterion, Serviceable, Configurable
068{
069    /** The ametys object resolver. */
070    protected AmetysObjectResolver _resolver;
071    
072    /** The content type extension point. */
073    protected ContentTypeExtensionPoint _cTypeEP;
074    
075    /** The content type extension point. */
076    protected ContentHelper _contentHelper;
077    
078    /** The user manager. */
079    protected UserManager _userManager;
080    
081    /** The user population DAO */
082    protected UserPopulationDAO _userPopulationDAO;
083    
084    private String _onInitClassName;
085    private String _onSubmitClassName;
086    private String _onChangeClassName;
087    private boolean _hidden;
088    private boolean _multiple;
089    private I18nizableText _group;
090    
091    private String _contentTypeId;
092    
093    @Override
094    public void service(ServiceManager manager) throws ServiceException
095    {
096        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
097        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
098        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
099        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
100        _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE);
101    }
102    
103    @Override
104    public void configure(Configuration configuration) throws ConfigurationException
105    {
106        configureId(configuration);
107        
108        configureLabelsAndGroup(configuration);
109        configureUIProperties(configuration);
110        configureValues(configuration);
111    }
112    
113    /**
114     * Configure the criterion ID.
115     * @param configuration The search criterion configuration.
116     * @throws ConfigurationException If an error occurs.
117     */
118    protected void configureId(Configuration configuration) throws ConfigurationException
119    {
120        setId(configuration.getAttribute("id", null));
121    }
122    
123    /**
124     * Configure the labels and group.
125     * @param configuration The search criterion configuration.
126     * @throws ConfigurationException If an error occurs.
127     */
128    protected void configureLabelsAndGroup(Configuration configuration) throws ConfigurationException
129    {
130        setGroup(_configureI18nizableText(configuration.getChild("group", false), null));
131        setLabel(_configureI18nizableText(configuration.getChild("label", false), new I18nizableText("")));
132        setDescription(_configureI18nizableText(configuration.getChild("description", false), new I18nizableText("")));
133    }
134    
135    /**
136     * Configure the default value.
137     * @param configuration The search criterion configuration.
138     * @throws ConfigurationException If an error occurs.
139     */
140    protected void configureValues(Configuration configuration) throws ConfigurationException
141    {
142        // The default value can be empty.
143        Configuration[] defaultValueConfs = configuration.getChildren("default-value");
144        if (defaultValueConfs.length == 1)
145        {
146            setDefaultValue(ConfigurationHelper.parseObject(defaultValueConfs[0], ""));
147        }
148        else if (defaultValueConfs.length > 1)
149        {
150            // Make the default value a list
151            List<Object> collection = Arrays.asList(defaultValueConfs).stream()
152                    .map(conf -> ConfigurationHelper.parseObject(conf, ""))
153                    .collect(Collectors.toList());
154            setDefaultValue(collection);
155        }
156    }
157    
158    /**
159     * Configure the standard UI properties (hidden, init class, change class, submit class).
160     * @param configuration The search criterion configuration.
161     * @throws ConfigurationException If an error occurs.
162     */
163    protected void configureUIProperties(Configuration configuration) throws ConfigurationException
164    {
165        setHidden(configuration.getAttributeAsBoolean("hidden", false));
166        setInitClassName(configuration.getChild("oninit").getValue(null));
167        setChangeClassName(configuration.getChild("onchange").getValue(null));
168        setSubmitClassName(configuration.getChild("onsubmit").getValue(null));
169    }
170    
171    /**
172     * Configure widget of the criterion
173     * @param configuration The search criterion configuration.
174     * @param defaultWidget The default widget to use if not present in configuration. If {@link MetadataType#RICH_TEXT} or {@link MetadataType#MULTILINGUAL_STRING}, it will be forced.
175     * @param type the type which must be supported by the widget
176     * @return the widget
177     */
178    protected String configureWidget(Configuration configuration, String defaultWidget, MetadataType type)
179    {
180        String realDefaultWidget = defaultWidget;
181        if ("edition.textarea".equals(realDefaultWidget))
182        {
183            realDefaultWidget = null;
184        }
185        else if (type == MetadataType.RICH_TEXT || type == MetadataType.MULTILINGUAL_STRING)
186        {
187            realDefaultWidget = "edition.textfield"; // Force simple text field
188        }
189        else if (realDefaultWidget == null && type == MetadataType.BOOLEAN)
190        {
191            realDefaultWidget = "edition.boolean-combobox";
192        }
193        
194        return configuration.getChild("widget").getValue(realDefaultWidget);
195    }
196    
197    /**
198     * Configure widget parameters of the criterion
199     * @param configuration The search criterion configuration.
200     * @param defaultParams The default widget parameters to override with configuration
201     * @param type the type which must be supported by the widget. If {@link MetadataType#CONTENT} or {@link MetadataType#SUB_CONTENT}, some parameters will be forced.
202     * @param contentTypeId For {@link MetadataType#CONTENT} or {@link MetadataType#SUB_CONTENT} types only. The id of the content type.
203     * @return the widget parameters
204     * @throws ConfigurationException If an error occurs.
205     */
206    protected Map<String, I18nizableText> configureWidgetParameters(Configuration configuration, Map<String, I18nizableText> defaultParams, MetadataType type, String contentTypeId) throws ConfigurationException
207    {
208        Configuration widgetParamsConfig = configuration.getChild("widget-params");
209        Map<String, I18nizableText> widgetParams = new HashMap<>(Optional.ofNullable(defaultParams).orElseGet(Collections::emptyMap));
210        
211        if (widgetParamsConfig != null)
212        {
213            // Overriden params
214            Configuration[] params = widgetParamsConfig.getChildren();
215            for (Configuration paramConfig : params)
216            {
217                String name = "param".equals(paramConfig.getName()) ? paramConfig.getAttribute("name") : paramConfig.getName();
218                widgetParams.put(name, I18nizableText.parseI18nizableText(paramConfig, null));
219            }
220        }
221        
222        // Force some param for types CONTENT and SUB_CONTENT
223        if (type == MetadataType.CONTENT || type == MetadataType.SUB_CONTENT)
224        {
225            setContentTypeId(contentTypeId);
226            if (contentTypeId != null)
227            {
228                widgetParams.put("contentType", new I18nizableText(contentTypeId));
229            }
230            
231            // Override the widget parameters to disable search and creation
232            widgetParams.put("allowCreation", new I18nizableText("false"));
233            widgetParams.put("allowSearch", new I18nizableText("false"));
234        }
235
236        return widgetParams;
237    }
238    
239    /**
240     * Get the JS class name to execute on 'init' event
241     * @return the JS class name to execute on 'init' event
242     */
243    public String getInitClassName()
244    {
245        return _onInitClassName;
246    }
247
248    /**
249     * Set the JS class name to execute on 'init' event
250     * @param className the JS class name 
251     */
252    public void setInitClassName(String className)
253    {
254        this._onInitClassName = className;
255    }
256
257    /**
258     * Get the JS class name to execute on 'submit' event
259     * @return the JS class name to execute on 'submit' event
260     */
261    public String getSubmitClassName()
262    {
263        return _onSubmitClassName;
264    }
265
266    /**
267     * Set the JS class name to execute on 'submit' event
268     * @param className the JS class name 
269     */
270    public void setSubmitClassName(String className)
271    {
272        this._onSubmitClassName = className;
273    }
274
275    /**
276     * Get the JS class name to execute on 'change' event
277     * @return the JS class name to execute on 'change' event
278     */
279    public String getChangeClassName()
280    {
281        return _onChangeClassName;
282    }
283
284    /**
285     * Set the JS class name to execute on 'change' event
286     * @param className the JS class name 
287     */
288    public void setChangeClassName(String className)
289    {
290        this._onChangeClassName = className;
291    }
292    
293    /**
294     * Get the group of the search criteria
295     * @return <code>null</code> if the search criteria does not belong to any group, the name of the group otherwise
296     */
297    public I18nizableText getGroup()
298    {
299        return _group;
300    }
301    
302    /**
303     * Set the group of the search criteria
304     * @param group the group this search criteria will be added to
305     */
306    public void setGroup(I18nizableText group)
307    {
308        _group = group;
309    }
310    
311    /**
312     * Determines if the criteria is hidden
313     * @return <code>true</code> if the criteria is hidden
314     */
315    public boolean isHidden()
316    {
317        return _hidden;
318    }
319    
320    /**
321     * Set the hidden property of the criteria
322     * @param hidden true to hide the search criteria
323     */
324    public void setHidden (boolean hidden)
325    {
326        this._hidden = hidden;
327    }
328    
329    /**
330     * Set the multiple property
331     * @param multiple the multiple property
332     */
333    public void setMultiple (boolean multiple)
334    {
335        this._multiple = multiple;
336    }
337    
338    /**
339     * Determines if the column value is multiple
340     * @return <code>true</code> if the value is multiple
341     */
342    @Override
343    public boolean isMultiple ()
344    {
345        return this._multiple;
346    }
347    
348    /**
349     * Get the content type ID (only when the search criteria is of type CONTENT).
350     * @return the content type ID.
351     */
352    public String getContentTypeId()
353    {
354        return this._contentTypeId;
355    }
356    
357    /**
358     * Set the content type ID (only when the search criteria is of type CONTENT).
359     * @param contentTypeId the content type ID. 
360     */
361    public void setContentTypeId(String contentTypeId)
362    {
363        this._contentTypeId = contentTypeId;
364    }
365    
366    @Override
367    public I18nizableText getFacetLabel(String value, Locale currentLocale)
368    {
369        I18nizableText label = null;
370        
371        try
372        {
373            MetadataType type = getType();
374            Enumerator enumerator = getEnumerator();
375            
376            if (type == MetadataType.CONTENT || type == MetadataType.SUB_CONTENT)
377            {
378                Content content = _resolver.resolveById(value);
379                label = new I18nizableText(content.getTitle(currentLocale));
380            }
381            else if (type == MetadataType.USER)
382            {
383                UserIdentity userIdentity = UserIdentity.stringToUserIdentity(value);
384                String login = userIdentity.getLogin();
385                String populationId = userIdentity.getPopulationId();
386                User user = _userManager.getUser(populationId, login);
387                if (user != null)
388                {
389                    Map<String, I18nizableText> i18nParams = new HashMap<>();
390                    i18nParams.put("login", new I18nizableText(login));
391                    i18nParams.put("fullname", new I18nizableText(user.getFullName()));
392                    i18nParams.put("population", _userPopulationDAO.getUserPopulation(populationId).getLabel());
393                    
394                    label = new I18nizableText("plugin.cms", "PLUGINS_CMS_UITOOL_SEARCH_FACET_USER_LABEL", i18nParams);
395                }
396            }
397            else if (enumerator != null)
398            {
399                label = enumerator.getEntry(value);
400            }
401            else if (type == MetadataType.BOOLEAN)
402            {
403                boolean boolValue = "1".equals(value) /* if joined facet, value will be "0" or "1" */ || Boolean.valueOf(value);
404                label = new I18nizableText("plugin.cms", boolValue ? "PLUGINS_CMS_UITOOL_SEARCH_FACET_BOOLEAN_TRUE_LABEL" : "PLUGINS_CMS_UITOOL_SEARCH_FACET_BOOLEAN_FALSE_LABEL");
405            }
406        }
407        catch (Exception e)
408        {
409            // Ignore, just return null.
410        }
411        
412        return label;
413    }
414    
415    @Override
416    public SearchField getSearchField()
417    {
418        // Override to provide a specific implementation.
419        return null;
420    }
421    
422    /**
423     * Initialize the validator.
424     * @param validatorManager The validator manager.
425     * @param pluginName The plugin name.
426     * @param role The validator role.
427     * @param config The validator configuration.
428     * @return true if the validator was successfully added, false otherwise.
429     * @throws ConfigurationException If an error occurs.
430     */
431    @SuppressWarnings("unchecked")
432    protected boolean _initializeValidator(ThreadSafeComponentManager<Validator> validatorManager, String pluginName, String role, Configuration config) throws ConfigurationException
433    {
434        Configuration validatorConfig = config.getChild("validation", false);
435        
436        if (validatorConfig != null)
437        {
438            String validatorClassName = StringUtils.defaultIfBlank(validatorConfig.getChild("custom-validator").getAttribute("class", ""), DefaultValidator.class.getName());
439            
440            try
441            {
442                Class validatorClass = Class.forName(validatorClassName);
443                validatorManager.addComponent(pluginName, null, role, validatorClass, config);
444                return true;
445            }
446            catch (Exception e)
447            {
448                throw new ConfigurationException("Unable to instantiate validator for class: " + validatorClassName, e);
449            }
450        }
451        
452        return false;
453    }
454    
455    /**
456     * Configure an i18nizable text
457     * @param config The Configuration.
458     * @param defaultValue The default value as an I18nizableText.
459     * @return The i18nizable text
460     */
461    protected I18nizableText _configureI18nizableText(Configuration config, I18nizableText defaultValue)
462    {
463        if (config != null)
464        {
465            return I18nizableText.parseI18nizableText(config, null, "");
466        }
467        else
468        {
469            return defaultValue;
470        }
471    }
472    
473    /**
474     * Get the metadata definition from the indexing field and compute the join paths. Can be null if the last indexing field is a custom indexing field.
475     * @param indexingField The initial indexing field
476     * @param remainingPathSegments The path to access the metadata or an another indexing field from the initial indexing field
477     * @param joinPaths The consecutive's path in case of joint to access the field/metadata
478     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
479     * @return The metadata definition or null if not found
480     * @throws ConfigurationException If an error occurs.
481     */
482    protected MetadataDefinition getMetadataDefinition(MetadataIndexingField indexingField, String[] remainingPathSegments, List<String> joinPaths, boolean addLast) throws ConfigurationException
483    {
484        StringBuilder currentMetaPath = new StringBuilder();
485        currentMetaPath.append(indexingField.getName());
486        
487        MetadataDefinition definition = indexingField.getMetadataDefinition();
488        
489        for (int i = 0; i < remainingPathSegments.length && definition != null; i++)
490        {
491            if (definition.getType() == MetadataType.CONTENT || definition.getType() == MetadataType.SUB_CONTENT)
492            {
493                // Add path to content from current content type to join paths.
494                // Join paths are the consecutive metadata paths (separated with '/') to access
495                // the searched content, for instance [address/city, links/department].
496                joinPaths.add(currentMetaPath.toString());
497                
498                String refCTypeId = definition.getContentType();
499                if (refCTypeId != null)
500                {
501                    if (!_cTypeEP.hasExtension(refCTypeId))
502                    {
503                        throw new ConfigurationException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' references an unknown content type:" + refCTypeId); 
504                    }
505                    
506                    ContentType refCType = _cTypeEP.getExtension(refCTypeId);
507                    IndexingModel refIndexingModel = refCType.getIndexingModel();
508                    
509                    IndexingField refIndexingField = refIndexingModel.getField(remainingPathSegments[i]);
510                    if (refIndexingField == null)
511                    {
512                        throw new ConfigurationException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + remainingPathSegments[i]);
513                    }
514                    if (!(refIndexingField instanceof MetadataIndexingField))
515                    {
516                        throw new ConfigurationException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + remainingPathSegments[i]);
517                    }
518                    
519                    return getMetadataDefinition((MetadataIndexingField) refIndexingField, ArrayUtils.subarray(remainingPathSegments, i + 1, remainingPathSegments.length), joinPaths, addLast);
520                }
521                else if ("title".equals(remainingPathSegments[i]))
522                {
523                    // No specific content type: allow only title.
524                    return ContentTypesHelper.getTitleMetadataDefinition();
525                }
526            }
527            else
528            {
529                if (definition instanceof RepeaterDefinition)
530                {
531                    // Add path to repeater from current content type or last repeater to join paths
532                    joinPaths.add(currentMetaPath.toString());
533                    currentMetaPath = new StringBuilder();
534                    currentMetaPath.append(remainingPathSegments[i]);
535                }
536                else
537                {
538                    currentMetaPath.append(ContentConstants.METADATA_PATH_SEPARATOR).append(remainingPathSegments[i]);
539                }
540                definition = definition.getMetadataDefinition(remainingPathSegments[i]);
541            }
542        }
543        
544        if (addLast)
545        {
546            joinPaths.add(currentMetaPath.toString());
547        }
548        
549        return definition;
550    }
551    
552}