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