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            
227            // Override the widget parameters to disable search and creation
228            widgetParams.put("allowCreation", new I18nizableText("false"));
229            widgetParams.put("allowSearch", new I18nizableText("false"));
230        }
231
232        return widgetParams;
233    }
234    
235    /**
236     * Get the JS class name to execute on 'init' event
237     * @return the JS class name to execute on 'init' event
238     */
239    public String getInitClassName()
240    {
241        return _onInitClassName;
242    }
243
244    /**
245     * Set the JS class name to execute on 'init' event
246     * @param className the JS class name 
247     */
248    public void setInitClassName(String className)
249    {
250        this._onInitClassName = className;
251    }
252
253    /**
254     * Get the JS class name to execute on 'submit' event
255     * @return the JS class name to execute on 'submit' event
256     */
257    public String getSubmitClassName()
258    {
259        return _onSubmitClassName;
260    }
261
262    /**
263     * Set the JS class name to execute on 'submit' event
264     * @param className the JS class name 
265     */
266    public void setSubmitClassName(String className)
267    {
268        this._onSubmitClassName = className;
269    }
270
271    /**
272     * Get the JS class name to execute on 'change' event
273     * @return the JS class name to execute on 'change' event
274     */
275    public String getChangeClassName()
276    {
277        return _onChangeClassName;
278    }
279
280    /**
281     * Set the JS class name to execute on 'change' event
282     * @param className the JS class name 
283     */
284    public void setChangeClassName(String className)
285    {
286        this._onChangeClassName = className;
287    }
288    
289    /**
290     * Get the group of the search criteria
291     * @return <code>null</code> if the search criteria does not belong to any group, the name of the group otherwise
292     */
293    public I18nizableText getGroup()
294    {
295        return _group;
296    }
297    
298    /**
299     * Set the group of the search criteria
300     * @param group the group this search criteria will be added to
301     */
302    public void setGroup(I18nizableText group)
303    {
304        _group = group;
305    }
306    
307    /**
308     * Determines if the criteria is hidden
309     * @return <code>true</code> if the criteria is hidden
310     */
311    public boolean isHidden()
312    {
313        return _hidden;
314    }
315    
316    /**
317     * Set the hidden property of the criteria
318     * @param hidden true to hide the search criteria
319     */
320    public void setHidden (boolean hidden)
321    {
322        this._hidden = hidden;
323    }
324    
325    /**
326     * Set the multiple property
327     * @param multiple the multiple property
328     */
329    public void setMultiple (boolean multiple)
330    {
331        this._multiple = multiple;
332    }
333    
334    /**
335     * Determines if the column value is multiple
336     * @return <code>true</code> if the value is multiple
337     */
338    @Override
339    public boolean isMultiple ()
340    {
341        return this._multiple;
342    }
343    
344    /**
345     * Get the content type ID (only when the search criteria is of type CONTENT).
346     * @return the content type ID.
347     */
348    public String getContentTypeId()
349    {
350        return this._contentTypeId;
351    }
352    
353    /**
354     * Set the content type ID (only when the search criteria is of type CONTENT).
355     * @param contentTypeId the content type ID. 
356     */
357    public void setContentTypeId(String contentTypeId)
358    {
359        this._contentTypeId = contentTypeId;
360    }
361    
362    @Override
363    public I18nizableText getFacetLabel(String value, Locale currentLocale)
364    {
365        I18nizableText label = null;
366        
367        try
368        {
369            MetadataType type = getType();
370            Enumerator enumerator = getEnumerator();
371            
372            if (type == MetadataType.CONTENT || type == MetadataType.SUB_CONTENT)
373            {
374                Content content = _resolver.resolveById(value);
375                label = new I18nizableText(content.getTitle(currentLocale));
376            }
377            else if (type == MetadataType.USER)
378            {
379                UserIdentity userIdentity = UserIdentity.stringToUserIdentity(value);
380                String login = userIdentity.getLogin();
381                String populationId = userIdentity.getPopulationId();
382                User user = _userManager.getUser(populationId, login);
383                if (user != null)
384                {
385                    Map<String, I18nizableText> i18nParams = new HashMap<>();
386                    i18nParams.put("login", new I18nizableText(login));
387                    i18nParams.put("fullname", new I18nizableText(user.getFullName()));
388                    i18nParams.put("population", _userPopulationDAO.getUserPopulation(populationId).getLabel());
389                    
390                    label = new I18nizableText("plugin.cms", "PLUGINS_CMS_UITOOL_SEARCH_FACET_USER_LABEL", i18nParams);
391                }
392            }
393            else if (enumerator != null)
394            {
395                label = enumerator.getEntry(value);
396            }
397            else if (type == MetadataType.BOOLEAN)
398            {
399                boolean boolValue = "1".equals(value) /* if joined facet, value will be "0" or "1" */ || Boolean.valueOf(value);
400                label = new I18nizableText("plugin.cms", boolValue ? "PLUGINS_CMS_UITOOL_SEARCH_FACET_BOOLEAN_TRUE_LABEL" : "PLUGINS_CMS_UITOOL_SEARCH_FACET_BOOLEAN_FALSE_LABEL");
401            }
402        }
403        catch (Exception e)
404        {
405            // Ignore, just return null.
406        }
407        
408        return label;
409    }
410    
411    @Override
412    public SearchField getSearchField()
413    {
414        // Override to provide a specific implementation.
415        return null;
416    }
417    
418    /**
419     * Initialize the validator.
420     * @param validatorManager The validator manager.
421     * @param pluginName The plugin name.
422     * @param role The validator role.
423     * @param config The validator configuration.
424     * @return true if the validator was successfully added, false otherwise.
425     * @throws ConfigurationException If an error occurs.
426     */
427    @SuppressWarnings("unchecked")
428    protected boolean _initializeValidator(ThreadSafeComponentManager<Validator> validatorManager, String pluginName, String role, Configuration config) throws ConfigurationException
429    {
430        Configuration validatorConfig = config.getChild("validation", false);
431        
432        if (validatorConfig != null)
433        {
434            String validatorClassName = StringUtils.defaultIfBlank(validatorConfig.getChild("custom-validator").getAttribute("class", ""), DefaultValidator.class.getName());
435            
436            try
437            {
438                Class validatorClass = Class.forName(validatorClassName);
439                validatorManager.addComponent(pluginName, null, role, validatorClass, config);
440                return true;
441            }
442            catch (Exception e)
443            {
444                throw new ConfigurationException("Unable to instantiate validator for class: " + validatorClassName, e);
445            }
446        }
447        
448        return false;
449    }
450    
451    /**
452     * Configure an i18nizable text
453     * @param config The Configuration.
454     * @param defaultValue The default value as an I18nizableText.
455     * @return The i18nizable text
456     */
457    protected I18nizableText _configureI18nizableText(Configuration config, I18nizableText defaultValue)
458    {
459        if (config != null)
460        {
461            return I18nizableText.parseI18nizableText(config, null, "");
462        }
463        else
464        {
465            return defaultValue;
466        }
467    }
468    
469    /**
470     * 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.
471     * @param indexingField The initial indexing field
472     * @param remainingPathSegments The path to access the metadata or an another indexing field from the initial indexing field
473     * @param joinPaths The consecutive's path in case of joint to access the field/metadata
474     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
475     * @return The metadata definition or null if not found
476     * @throws ConfigurationException If an error occurs.
477     */
478    protected MetadataDefinition getMetadataDefinition(MetadataIndexingField indexingField, String[] remainingPathSegments, List<String> joinPaths, boolean addLast) throws ConfigurationException
479    {
480        StringBuilder currentMetaPath = new StringBuilder();
481        currentMetaPath.append(indexingField.getName());
482        
483        MetadataDefinition definition = indexingField.getMetadataDefinition();
484        
485        for (int i = 0; i < remainingPathSegments.length && definition != null; i++)
486        {
487            if (definition.getType() == MetadataType.CONTENT || definition.getType() == MetadataType.SUB_CONTENT)
488            {
489                // Add path to content from current content type to join paths.
490                // Join paths are the consecutive metadata paths (separated with '/') to access
491                // the searched content, for instance [address/city, links/department].
492                joinPaths.add(currentMetaPath.toString());
493                
494                String refCTypeId = definition.getContentType();
495                if (refCTypeId != null)
496                {
497                    if (!_cTypeEP.hasExtension(refCTypeId))
498                    {
499                        throw new ConfigurationException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' references an unknown content type:" + refCTypeId); 
500                    }
501                    
502                    ContentType refCType = _cTypeEP.getExtension(refCTypeId);
503                    IndexingModel refIndexingModel = refCType.getIndexingModel();
504                    
505                    IndexingField refIndexingField = refIndexingModel.getField(remainingPathSegments[i]);
506                    if (refIndexingField == null)
507                    {
508                        throw new ConfigurationException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + remainingPathSegments[i]);
509                    }
510                    if (!(refIndexingField instanceof MetadataIndexingField))
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                    
515                    return getMetadataDefinition((MetadataIndexingField) refIndexingField, ArrayUtils.subarray(remainingPathSegments, i + 1, remainingPathSegments.length), joinPaths, addLast);
516                }
517                else if ("title".equals(remainingPathSegments[i]))
518                {
519                    // No specific content type: allow only title.
520                    return ContentTypesHelper.getTitleMetadataDefinition();
521                }
522            }
523            else
524            {
525                if (definition instanceof RepeaterDefinition)
526                {
527                    // Add path to repeater from current content type or last repeater to join paths
528                    joinPaths.add(currentMetaPath.toString());
529                    currentMetaPath = new StringBuilder();
530                    currentMetaPath.append(remainingPathSegments[i]);
531                }
532                else
533                {
534                    currentMetaPath.append(ContentConstants.METADATA_PATH_SEPARATOR).append(remainingPathSegments[i]);
535                }
536                definition = definition.getMetadataDefinition(remainingPathSegments[i]);
537            }
538        }
539        
540        if (addLast)
541        {
542            joinPaths.add(currentMetaPath.toString());
543        }
544        
545        return definition;
546    }
547    
548}