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