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}