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