001/*
002 *  Copyright 2025 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.web.model.properties;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Locale;
022import java.util.Map;
023import java.util.Optional;
024import java.util.Set;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.configuration.ConfigurationException;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.components.ContextHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.commons.lang3.BooleanUtils;
034import org.apache.commons.lang3.StringUtils;
035import org.apache.solr.common.SolrInputDocument;
036
037import org.ametys.cms.ObservationConstants;
038import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
039import org.ametys.cms.data.type.indexing.IndexableElementType;
040import org.ametys.cms.model.CMSDataContext;
041import org.ametys.cms.model.properties.ElementReferencingProperty;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ContentQueryHelper;
044import org.ametys.cms.repository.ContentTypeExpression;
045import org.ametys.cms.repository.LanguageExpression;
046import org.ametys.cms.search.QueryBuilder;
047import org.ametys.cms.search.content.ContentSearchHelper;
048import org.ametys.cms.search.content.ContentSearchHelper.JoinedPaths;
049import org.ametys.cms.search.query.JoinQuery;
050import org.ametys.cms.search.query.Query;
051import org.ametys.cms.search.query.Query.Operator;
052import org.ametys.core.cache.AbstractCacheManager;
053import org.ametys.core.cache.Cache;
054import org.ametys.core.observation.Event;
055import org.ametys.core.observation.ObservationManager;
056import org.ametys.core.observation.Observer;
057import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
058import org.ametys.plugins.repository.AmetysObjectIterable;
059import org.ametys.plugins.repository.AmetysObjectResolver;
060import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
061import org.ametys.plugins.repository.provider.WorkspaceSelector;
062import org.ametys.plugins.repository.query.expression.AndExpression;
063import org.ametys.plugins.repository.query.expression.Expression;
064import org.ametys.runtime.i18n.I18nizableText;
065import org.ametys.runtime.i18n.I18nizableTextParameter;
066import org.ametys.runtime.model.ElementDefinition;
067import org.ametys.runtime.model.Enumerator;
068import org.ametys.runtime.model.Model;
069import org.ametys.runtime.model.ModelItem;
070import org.ametys.runtime.model.type.ModelItemTypeConstants;
071
072/**
073 * Property that enumerates actual values of its referenced {@link ElementDefinition}
074 * @param <T> Type of the element value
075 */
076public class EnumeratingProperty<T> extends ElementReferencingProperty<T, Content> implements Initializable, Observer
077{
078    private static final List<String> __ALLOWED_ELEMENT_TYPES = List.of(
079        ModelItemTypeConstants.STRING_TYPE_ID,
080        ModelItemTypeConstants.LONG_TYPE_ID,
081        ModelItemTypeConstants.DOUBLE_TYPE_ID,
082        ModelItemTypeConstants.DATE_TYPE_ID,
083        ModelItemTypeConstants.DATETIME_TYPE_ID,
084        org.ametys.cms.data.type.ModelItemTypeConstants.GEOCODE_ELEMENT_TYPE_ID,
085        org.ametys.cms.data.type.ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID,
086        org.ametys.cms.data.type.ModelItemTypeConstants.REFERENCE_ELEMENT_TYPE_ID
087    );
088    
089    private static final String __VALUES_CACHE_NAME_PREFIX = EnumeratingProperty.class.getName() + "$values$";
090    private final String _uniqueCacheSuffix = org.ametys.core.util.StringUtils.generateKey();
091    
092    private AmetysObjectResolver _resolver;
093    private WorkspaceSelector _workspaceSelector;
094    private ContentSearchHelper _contentSearchHelper;
095    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
096    
097    private AbstractCacheManager _cacheManager;
098    private ObservationManager _observationManager;
099    
100    private String _parsedContentTypeId;
101    private Model _contentType;
102    
103    @Override
104    public void service(ServiceManager manager) throws ServiceException
105    {
106        super.service(manager);
107        
108        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
109        _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE);
110        _contentSearchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE);
111        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
112        
113        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
114        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
115    }
116    
117    @Override
118    public void configure(Configuration configuration) throws ConfigurationException
119    {
120        super.configure(configuration);
121        
122        _parsedContentTypeId = configuration.getChild("content-type").getValue(null);
123    }
124    
125    public void initialize() throws Exception
126    {
127        _createCaches();
128        _observationManager.registerObserver(this);
129    }
130    
131    @Override
132    public void init(String availableTypesRole) throws Exception
133    {
134        super.init(availableTypesRole);
135        
136        ElementDefinition<T> referenceDefinition = _getReferenceDefinition(_reference);
137
138        // Check content type
139        if (StringUtils.isNotEmpty(_parsedContentTypeId))
140        {
141            if (_contentTypeExtensionPoint.hasExtension(_parsedContentTypeId))
142            {
143                _contentType = _contentTypeExtensionPoint.getExtension(_parsedContentTypeId);
144            }
145            else
146            {
147                throw new ConfigurationException("Unable to create the property '" + getName() + "'. The configured content type '" + _parsedContentTypeId + "' does not exist.");
148            }
149        }
150        else
151        {
152            _contentType = referenceDefinition.getModel();
153        }
154        
155        // Check allowed data types
156        String typeId = getTypeId();
157        if (!__ALLOWED_ELEMENT_TYPES.contains(typeId))
158        {
159            throw new ConfigurationException("'" + _reference + "' is invalid for enumerating property '" + getName() + "'. The type '" + typeId + "' is not allowed.");
160        }
161        
162        // Creates the enumerator instance
163        Enumerator<T> enumerator = new ActualValuesEnumerator(referenceDefinition);
164        setEnumerator(enumerator);
165    }
166    
167    private void _createCaches()
168    {
169        _cacheManager.createMemoryCache(__VALUES_CACHE_NAME_PREFIX + _uniqueCacheSuffix, 
170                _buildI18n("PLUGINS_CMS_CACHE_ACTUAL_VALUES_ENUMERATOR_LABEL"),
171                _buildI18n("PLUGINS_CMS_CACHE_ACTUAL_VALUES_ENUMERATOR_DESCRIPTION"),
172                true,
173                null);
174    }
175    
176    private I18nizableText _buildI18n(String i18nKey)
177    {
178        String catalogue = "plugin.web";
179        I18nizableText elementPath = new I18nizableText(_reference);
180        Map<String, I18nizableTextParameter> labelParams = Map.of("path", elementPath);
181        return new I18nizableText(catalogue, i18nKey, labelParams);
182    }
183    
184    private Map<T, I18nizableText> _extractActualValues()
185    {
186        Map<T, I18nizableText> actualValues = new HashMap<>();
187        
188        List<Expression> exprs = new ArrayList<>();
189        
190        String contentTypeId = _contentType.getId();
191        exprs.add(new ContentTypeExpression(org.ametys.plugins.repository.query.expression.Expression.Operator.EQ, contentTypeId));
192        
193        String lang = _getSitemapLanguage();
194        if (StringUtils.isNotBlank(lang))
195        {
196            exprs.add(new LanguageExpression(org.ametys.plugins.repository.query.expression.Expression.Operator.EQ, lang));
197        }
198        
199        String query = ContentQueryHelper.getContentXPathQuery(new AndExpression(exprs.toArray(new Expression[exprs.size()])));
200        AmetysObjectIterable<Content> contents = _resolver.query(query);
201        boolean isMultiple = DataHolderHelper.isMultiple(_contentType, _reference);
202        for (Content content : contents)
203        {
204            if (isMultiple)
205            {
206                T[] values = content.getValue(_reference, true);
207                if (values != null)
208                {
209                    for (T value : values)
210                    {
211                        String valuesAsString = getType().toString(value);
212                        actualValues.put(value, new I18nizableText(valuesAsString));
213                    }
214                }
215            }
216            else
217            {
218                if (content.hasValue(_reference))
219                {
220                    T value = content.getValue(_reference);
221                    String valuesAsString = getType().toString(value);
222                    actualValues.put(value, new I18nizableText(valuesAsString));
223                }
224            }
225        }
226
227        return actualValues;
228    }
229    
230    private String _getSitemapLanguage()
231    {
232        Request request = ContextHelper.getRequest(_context);
233        Object attribute = request.getAttribute("sitemapLanguage");
234        return attribute != null ? (String) attribute : null;
235    }
236    
237    public Query getQuery(Object value, Operator operator, String language, Map<String, Object> contextualParameters)
238    {
239        CMSDataContext context = CMSDataContext.newInstance()
240                                               .withMultilingualSearch(contextualParameters.containsKey(QueryBuilder.MULTILINGUAL_SEARCH))
241                                               .withSearchedValueEscaped(BooleanUtils.isTrue((Boolean) contextualParameters.get(QueryBuilder.VALUE_IS_ESCAPED)))
242                                               .withLocale(Locale.forLanguageTag(language))
243                                               .withModelItem(this);
244        
245        ElementDefinition<T> referenceDefinition = _getReferenceDefinition(_reference);
246        String queryFieldPath = _contentSearchHelper.computeQueryFieldPath(referenceDefinition);
247        IndexableElementType referenceType = (IndexableElementType) referenceDefinition.getType();
248        Query query = referenceType.getDefaultQuery(value, queryFieldPath, operator, false, context);
249        
250        List<String> queryJoinPaths = _getJoinedPaths(referenceDefinition);
251        if (query != null && !queryJoinPaths.isEmpty())
252        {
253            query = new JoinQuery(query, queryJoinPaths);
254        }
255        
256        return query;
257    }
258    
259    private List<String> _getJoinedPaths(ModelItem reference)
260    {
261        JoinedPaths computedJoinedPaths = _contentSearchHelper.computeJoinedPaths(reference.getPath(), Set.of(_contentType.getId()));
262        return computedJoinedPaths.joinedPaths();
263    }
264    
265    public void indexValue(SolrInputDocument document, Content ametysObject, CMSDataContext context)
266    {
267        // Do nothing, the referenced element is already indexed
268        // The only pupose of this property is to enumerate values of the referenced element
269    }
270
271    public int getPriority()
272    {
273        return 0;
274    }
275
276    public boolean supports(Event event)
277    {
278        if ((event.getId().equals(ObservationConstants.EVENT_CONTENT_MODIFIED) || event.getId().equals(ObservationConstants.EVENT_CONTENT_VALIDATED))
279                && (event.getArguments().containsKey(ObservationConstants.ARGS_CONTENT) || event.getArguments().containsKey(ObservationConstants.ARGS_CONTENT_ID)))
280        {
281            Content content = event.getArguments().containsKey(ObservationConstants.ARGS_CONTENT)
282                    ? (Content) event.getArguments().get(ObservationConstants.ARGS_CONTENT)
283                    : _resolver.resolveById((String) event.getArguments().get(ObservationConstants.ARGS_CONTENT_ID));
284            
285            return content.getModel().contains(_contentType);
286        }
287        else
288        {
289            return false;
290        }
291    }
292    
293    public void observe(Event event, Map<String, Object> transientVars) throws Exception
294    {
295        _getValuesCache().invalidateAll();
296    }
297    
298    private Cache<CacheKey, Map<T, I18nizableText>> _getValuesCache()
299    {
300        return _cacheManager.get(__VALUES_CACHE_NAME_PREFIX + _uniqueCacheSuffix);
301    }
302    
303    /**
304     * Enumerator listing actual values of the given element
305     */
306    private class ActualValuesEnumerator implements Enumerator<T>
307    {
308        private ElementDefinition<T> _referenceDefinition;
309
310        /**
311         * Constructor for {@link ActualValuesEnumerator}
312         * @param element the element containing values to retrieve
313         */
314        public ActualValuesEnumerator(ElementDefinition<T> element)
315        {
316            _referenceDefinition = element;
317        }
318        
319        public Map<T, I18nizableText> getEntries() throws Exception
320        {
321            String lang = Optional.ofNullable(_getSitemapLanguage())
322                                  .orElse(StringUtils.EMPTY);
323            String workspace = _workspaceSelector.getWorkspace();
324            return _getValuesCache().get(CacheKey.of(_referenceDefinition.getPath(), _contentType.getId(), lang, workspace),  __ -> _extractActualValues());
325        }
326    }
327    
328    static final class CacheKey extends AbstractCacheKey
329    {
330        private CacheKey(String elementPath, String contentTypeId, String lang, String workspace)
331        {
332            super(elementPath, contentTypeId, lang, workspace);
333        }
334
335        static CacheKey of(String elementPath, String contentTypeId, String lang, String workspace)
336        {
337            return new CacheKey(elementPath, contentTypeId, lang, workspace);
338        }
339    }
340}