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.model; 017 018import java.util.Collection; 019import java.util.HashSet; 020import java.util.List; 021import java.util.Map; 022import java.util.Optional; 023import java.util.Set; 024import java.util.stream.Collectors; 025 026import org.apache.avalon.framework.component.Component; 027import org.apache.avalon.framework.configuration.Configuration; 028import org.apache.avalon.framework.context.Context; 029import org.apache.avalon.framework.context.ContextException; 030import org.apache.avalon.framework.context.Contextualizable; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.apache.cocoon.ProcessingException; 035import org.apache.commons.lang3.StringUtils; 036 037import org.ametys.cms.contenttype.ContentTypeEnumerator; 038import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 039import org.ametys.cms.contenttype.ContentTypesHelper; 040import org.ametys.cms.languages.Language; 041import org.ametys.cms.languages.LanguagesManager; 042import org.ametys.cms.search.model.impl.ContentTypesAwareReferencingCriterionDefinition; 043import org.ametys.cms.search.model.impl.ReferencingSearchModelCriterionDefinition; 044import org.ametys.cms.search.model.impl.SolrFilterCriterionDefinition; 045import org.ametys.cms.search.query.ContentTypeQuery; 046import org.ametys.cms.search.query.MixinTypeQuery; 047import org.ametys.cms.search.query.OrQuery; 048import org.ametys.cms.search.query.Query; 049import org.ametys.cms.search.query.Query.Operator; 050import org.ametys.cms.search.ui.model.SearchModelCriterionViewItem; 051import org.ametys.cms.search.ui.model.SearchUIModel; 052import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint; 053import org.ametys.cms.search.ui.model.impl.DefaultSearchUIModel; 054import org.ametys.core.ui.Callable; 055import org.ametys.runtime.model.Enumerator; 056import org.ametys.runtime.model.ModelViewItem; 057import org.ametys.runtime.model.View; 058import org.ametys.runtime.model.ViewItem; 059import org.ametys.runtime.model.ViewItemAccessor; 060import org.ametys.runtime.model.ViewItemContainer; 061import org.ametys.runtime.plugin.component.AbstractLogEnabled; 062import org.ametys.runtime.plugin.component.ThreadSafeComponentManager; 063 064/** 065 * Helper for {@link SearchModel}. 066 */ 067public class SearchModelHelper extends AbstractLogEnabled implements Component, Contextualizable, Serviceable 068{ 069 /** The component role. */ 070 public static final String ROLE = SearchModelHelper.class.getName(); 071 072 /** The query default language. */ 073 public static final String DEFAULT_LANGUAGE = "en"; 074 075 private static final String __SOLR_REQUEST_PARAMETER_NAME = "solrRequest"; 076 private static final String __RESTRICTED_CONTENT_TYPE_PARAMETER_NAME = "restrictedContentType"; 077 private static final String __SOLR_REQUEST_CRITERION_ID = "solr-filter-criterion"; 078 079 private Context _context; 080 private ServiceManager _manager; 081 082 private SearchUIModelExtensionPoint _searchUIModelExtensionPoint; 083 private ContentTypeExtensionPoint _contentTypeExtensionPoint; 084 private ContentTypesHelper _contentTypesHelper; 085 private SearchModelCriterionDefinitionHelper _searchModelCriterionDefinitionHelper; 086 private LanguagesManager _languagesManager; 087 088 public void contextualize(Context context) throws ContextException 089 { 090 _context = context; 091 } 092 093 @Override 094 public void service(ServiceManager manager) throws ServiceException 095 { 096 _manager = manager; 097 098 _searchUIModelExtensionPoint = (SearchUIModelExtensionPoint) manager.lookup(SearchUIModelExtensionPoint.ROLE); 099 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 100 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 101 _searchModelCriterionDefinitionHelper = (SearchModelCriterionDefinitionHelper) manager.lookup(SearchModelCriterionDefinitionHelper.ROLE); 102 _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE); 103 } 104 105 /** 106 * Get the search model configuration as JSON object 107 * @param modelId The id of search model 108 * @param contextualParameters the contextual parameters 109 * @return The search model configuration in a Map 110 * @throws ProcessingException if an error occurred 111 */ 112 @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public 113 public Map<String, Object> getSearchModelConfiguration(String modelId, Map<String, Object> contextualParameters) throws ProcessingException 114 { 115 SearchUIModel model = getSearchUIModel(modelId, contextualParameters); 116 return model.toJSON(contextualParameters); 117 } 118 119 /** 120 * Get the search model configuration as JSON object. 121 * Add some restrictions on the search model, due to the given contextual parameters (content types / solr request) 122 * @param modelId The id of search model 123 * @param contextualParameters the contextual parameters 124 * @return The search model configuration in a Map 125 * @throws ProcessingException if an error occurred while converting the model to JSON 126 */ 127 @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public 128 public Map<String, Object> getRestrictedSearchModelConfiguration(String modelId, Map<String, Object> contextualParameters) throws ProcessingException 129 { 130 SearchModel model = getRestrictedSearchModel(modelId, contextualParameters); 131 return model.toJSON(contextualParameters); 132 } 133 134 /** 135 * Get the search model with the given identifier 136 * Add some restrictions on the search model, due to the given contextual parameters (content types / solr request) 137 * @param modelId The id of search model 138 * @param contextualParameters the contextual parameters 139 * @return The search model 140 */ 141 public SearchModel getRestrictedSearchModel(String modelId, Map<String, Object> contextualParameters) 142 { 143 SearchUIModel model = getSearchUIModel(modelId, contextualParameters); 144 145 // Copy is needed because some modifications will be made on the search model 146 DefaultSearchModel copy = copySearchModel(model, contextualParameters); 147 148 String restrictedContentTypeId = (String) contextualParameters.get(__RESTRICTED_CONTENT_TYPE_PARAMETER_NAME); 149 if (StringUtils.isNotEmpty(restrictedContentTypeId)) 150 { 151 addContentTypeRestrictions(copy, restrictedContentTypeId, contextualParameters); 152 } 153 154 String solrRequest = (String) contextualParameters.get(__SOLR_REQUEST_PARAMETER_NAME); 155 if (StringUtils.isNotEmpty(solrRequest)) 156 { 157 addSolrFilterCriterion(copy, solrRequest, contextualParameters); 158 } 159 160 return copy; 161 } 162 163 /** 164 * Get the column configurations of search model as JSON object 165 * @param modelId The id of search model 166 * @param contextualParameters the contextual parameters 167 * @return The column configurations in a List 168 * @throws ProcessingException if an error occurred 169 */ 170 @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public 171 public List<Object> getColumnConfigurations(String modelId, Map<String, Object> contextualParameters) throws ProcessingException 172 { 173 SearchUIModel model = getSearchUIModel(modelId, contextualParameters); 174 return model.resultItemsToJSON(contextualParameters); 175 } 176 177 /** 178 * Retrieves the {@link SearchUIModel} with the given model identifier, with restrictions on content types 179 * @param modelId the model identifier 180 * @param contextualParameters the contextual parameters 181 * @return the {@link SearchUIModel} 182 */ 183 public SearchUIModel getSearchUIModel(String modelId, Map<String, Object> contextualParameters) 184 { 185 return Optional.ofNullable(_searchUIModelExtensionPoint.getExtension(modelId)) 186 .orElseThrow(() -> new IllegalArgumentException("The search model '" + modelId + "' does not exist")); 187 } 188 189 /** 190 * Copy the given search model 191 * @param model the model to copy 192 * @param contextualParameters the contextual parameters 193 * @return the copy of the model 194 */ 195 public DefaultSearchModel copySearchModel(SearchModel model, Map<String, Object> contextualParameters) 196 { 197 return model instanceof SearchUIModel uiModel 198 ? new DefaultSearchUIModel(uiModel, contextualParameters) 199 : new DefaultSearchModel(model, contextualParameters); 200 } 201 202 /** 203 * Add content type restrictions on the given search model 204 * Add content types to the model and restrict enumerator values of its content types criteria 205 * @param model the model 206 * @param restrictedContentTypeId the selected content type identifier 207 * @param contextualParameters the contextual parameters 208 */ 209 public void addContentTypeRestrictions(DefaultSearchModel model, String restrictedContentTypeId, Map<String, Object> contextualParameters) 210 { 211 // Get the content types to set on the model - combination of the types configured in the model and the restricted type 212 Set<String> contentTypeIds = _getRestrictedSearchModelContentTypeIds(model, restrictedContentTypeId); 213 model.setContentTypes(contentTypeIds); 214 215 ViewItemContainer criteria = model.getCriteria(contextualParameters); 216 Set<String> contentTypeAndSubTypeIds = contentTypeIds.stream() 217 .map(this::_getSelfAndSubTypeIds) 218 .flatMap(Set::stream) 219 .collect(Collectors.toSet()); 220 _addContentTypeRestrictions(criteria, contentTypeAndSubTypeIds, contextualParameters); 221 } 222 223 private Set<String> _getRestrictedSearchModelContentTypeIds(DefaultSearchModel model, String restrictedContentTypeId) 224 { 225 Set<String> contentTypeIds = new HashSet<>(); 226 contentTypeIds.add(restrictedContentTypeId); 227 228 for (String modelContentTypeId : model.getContentTypes(null)) 229 { 230 if (_isSubTypeOf(modelContentTypeId, restrictedContentTypeId)) 231 { 232 // If the content type from the model is more specific that the one used for restriction, keep it 233 contentTypeIds.add(modelContentTypeId); 234 contentTypeIds.remove(restrictedContentTypeId); 235 } 236 } 237 238 return contentTypeIds; 239 } 240 241 private boolean _isSubTypeOf(String contentTypeId, String parentContentTypeId) 242 { 243 for (String supertypeId : _contentTypeExtensionPoint.getExtension(contentTypeId).getSupertypeIds()) 244 { 245 if (supertypeId.equals(parentContentTypeId) || _isSubTypeOf(supertypeId, parentContentTypeId)) 246 { 247 return true; 248 } 249 } 250 return false; 251 } 252 253 private Set<String> _getSelfAndSubTypeIds(String contentTypeId) 254 { 255 Set<String> contentTypeIds = new HashSet<>(); 256 257 contentTypeIds.add(contentTypeId); 258 contentTypeIds.addAll(_contentTypeExtensionPoint.getSubTypes(contentTypeId)); 259 260 return contentTypeIds; 261 } 262 263 @SuppressWarnings("unchecked") 264 private void _addContentTypeRestrictions(ViewItemContainer criteria, Set<String> restrictedContentTypeIds, Map<String, Object> contextualParameters) 265 { 266 for (ViewItem viewItem : criteria.getViewItems()) 267 { 268 if (viewItem instanceof ModelViewItem modelViewItem 269 && modelViewItem.getDefinition() instanceof ContentTypesAwareCriterionDefinition criterion) 270 { 271 Enumerator<String> enumerator = _getContentTypesEnumerator(restrictedContentTypeIds.toArray(String[]::new), criterion.includeContentTypes(), criterion.includeMixins()); 272 if (enumerator != null) 273 { 274 SearchModelCriterionDefinition<String> criterionCopy = new ContentTypesAwareReferencingCriterionDefinition(); 275 criterion.copyTo(criterionCopy, contextualParameters); 276 277 criterionCopy.setEnumerator(enumerator); 278 modelViewItem.setDefinition(criterionCopy); 279 } 280 } 281 282 if (viewItem instanceof ViewItemContainer viewItemContainer) 283 { 284 _addContentTypeRestrictions(viewItemContainer, restrictedContentTypeIds, contextualParameters); 285 } 286 } 287 } 288 289 private Enumerator<String> _getContentTypesEnumerator(String[] contentTypeIds, boolean includeContentTypes, boolean incudeMixins) 290 { 291 ThreadSafeComponentManager<Enumerator> enumeratorManager = new ThreadSafeComponentManager<>(); 292 try 293 { 294 enumeratorManager.setLogger(getLogger()); 295 enumeratorManager.contextualize(_context); 296 enumeratorManager.service(_manager); 297 298 String role = "enumerator"; 299 Configuration configuration = _contentTypesHelper.getContentTypesEnumeratorConfiguration(contentTypeIds, includeContentTypes, incudeMixins); 300 enumeratorManager.addComponent("cms", null, role, ContentTypeEnumerator.class, configuration); 301 302 enumeratorManager.initialize(); 303 return enumeratorManager.lookup(role); 304 } 305 catch (Exception e) 306 { 307 getLogger().error("Unable to create a content types enumerator on types '" + StringUtils.join(contentTypeIds, ",") + "'", e); 308 return null; 309 } 310 finally 311 { 312 enumeratorManager.dispose(); 313 enumeratorManager = null; 314 } 315 } 316 317 /** 318 * Add a solr filter criterion to the given model 319 * @param model the model 320 * @param solrRequest the solr request 321 * @param contextualParameters the contextual parameters 322 */ 323 public void addSolrFilterCriterion(SearchModel model, String solrRequest, Map<String, Object> contextualParameters) 324 { 325 // Create a criterion with solr request 326 SolrFilterCriterionDefinition criterion = new SolrFilterCriterionDefinition(); 327 criterion.setName(__SOLR_REQUEST_CRITERION_ID); 328 criterion.setQuery(solrRequest); 329 criterion.setModel(model); 330 331 // Add the criterion to the search model 332 model.addCriterion(criterion, contextualParameters); 333 334 // Hide the criterion 335 ModelViewItem viewItem = model.getCriterion(__SOLR_REQUEST_CRITERION_ID, contextualParameters); 336 if (viewItem instanceof SearchModelCriterionViewItem criterionViewItem) 337 { 338 criterionViewItem.setHidden(true); 339 } 340 } 341 342 /** 343 * Set faceted criteria to the given search model from the given reference paths 344 * If there is no reference path, the faceted criteria of the model are not changed 345 * @param model the search model 346 * @param referencePaths the reference paths 347 * @param contextualParameters the contextual parameters 348 */ 349 @SuppressWarnings("unchecked") 350 public void setFacetedCriteria(DefaultSearchModel model, Collection<String> referencePaths, Map<String, Object> contextualParameters) 351 { 352 if (referencePaths != null && !referencePaths.isEmpty()) 353 { 354 ViewItemContainer originalFacetedCriteria = model.getFacetedCriteria(contextualParameters); 355 model.setFacetedCriteria(new View()); 356 357 for (String referencePath : referencePaths) 358 { 359 SearchModelCriterionDefinition referenceCriterion = _findCriterionByReferencePath(originalFacetedCriteria, referencePath); 360 if (referenceCriterion != null) 361 { 362 model.addFacetedCriterion(referenceCriterion, contextualParameters); 363 } 364 else 365 { 366 SearchModelCriterionDefinition criterion = _searchModelCriterionDefinitionHelper.createReferencingCriterionDefinition(model, referencePath, model.getContentTypes(contextualParameters)); 367 if (criterion != null && criterion.getSolrFacetFieldName(contextualParameters) != null) 368 { 369 model.addFacetedCriterion(criterion, contextualParameters); 370 } 371 else 372 { 373 getLogger().warn("The declared facet '{}' is not facetable. Thus, it will not be added to the facets.", referencePath); 374 } 375 } 376 } 377 378 } 379 } 380 381 private SearchModelCriterionDefinition _findCriterionByReferencePath(ViewItemContainer viewItemContainer, String referencePath) 382 { 383 for (ViewItem viewItem : viewItemContainer.getViewItems()) 384 { 385 if (viewItem instanceof ModelViewItem modelViewItem 386 && modelViewItem.getDefinition() instanceof ReferencingSearchModelCriterionDefinition criterion 387 && criterion.getReferencePath().equals(referencePath)) 388 { 389 return criterion; 390 } 391 392 if (viewItem instanceof ViewItemContainer newViewItemContainer) 393 { 394 return _findCriterionByReferencePath(newViewItemContainer, referencePath); 395 } 396 } 397 return null; 398 } 399 400 /** 401 * Get the language. 402 * @param model the search model 403 * @param searchMode The search mode (advanced or simple) 404 * @param values The user values. 405 * @param contextualParameters The search contextual parameters. 406 * @return the query language. 407 */ 408 public String getCriteriaLanguage(SearchModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters) 409 { 410 ViewItemContainer criteria = "advanced".equals(searchMode) && model instanceof SearchUIModel uiModel 411 ? uiModel.getAdvancedCriteria(contextualParameters) 412 : model.getCriteria(contextualParameters); 413 414 // First search language in criteria 415 String langValue = _getLanguageFromContentLanguageCriterion(criteria, values, contextualParameters); 416 417 if (StringUtils.isEmpty(langValue)) 418 { 419 // If empty, get language from the search contextual parameters (for instance, sent by the tool). 420 langValue = (String) contextualParameters.get("language"); 421 } 422 423 if (StringUtils.isEmpty(langValue)) 424 { 425 // If no language found: fall back to default. 426 langValue = _getDefaultLanguage(); 427 } 428 429 return langValue; 430 } 431 432 private String _getLanguageFromContentLanguageCriterion(ViewItemAccessor criteria, Map<String, Object> values, Map<String, Object> contextualParameters) 433 { 434 if (criteria != null) 435 { 436 for (ViewItem viewItem : criteria.getViewItems()) 437 { 438 if (viewItem instanceof ModelViewItem modelViewItem 439 && modelViewItem.getDefinition() instanceof LanguageAwareCriterionDefinition criterion) 440 { 441 Object value = modelViewItem instanceof SearchModelCriterionViewItem criterionViewItem && criterionViewItem.isHidden() 442 ? criterion.getDefaultValue() 443 : values.get(criterion.getName()); 444 445 return criterion.getLanguage(value, values, contextualParameters); 446 } 447 else if (viewItem instanceof ViewItemContainer itemContainer) 448 { 449 return _getLanguageFromContentLanguageCriterion(itemContainer, values, contextualParameters); 450 } 451 } 452 } 453 454 return null; 455 } 456 457 private String _getDefaultLanguage() 458 { 459 Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages(); 460 if (availableLanguages.containsKey(DEFAULT_LANGUAGE)) 461 { 462 return DEFAULT_LANGUAGE; 463 } 464 465 return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE; 466 } 467 468 /** 469 * Create a content type or mixin query. 470 * @param contentTypes the content types or mixins to search on. 471 * @return the content type {@link Query}. 472 */ 473 public Query createContentTypeOrMixinQuery(Collection<String> contentTypes) 474 { 475 return createContentTypeOrMixinQuery(contentTypes, Operator.EQ); 476 } 477 478 /** 479 * Create a content type or mixin query. 480 * @param contentTypes the content types or mixins to search on. 481 * @param operator The operator to use in created query 482 * @return the content type {@link Query}. 483 */ 484 public Query createContentTypeOrMixinQuery(Collection<String> contentTypes, Operator operator) 485 { 486 if (contentTypes == null || contentTypes.isEmpty()) // empty and non-empty model contentTypes 487 { 488 return null; 489 } 490 491 List<String> onlyMixins = contentTypes.stream() 492 .filter(ct -> _contentTypeExtensionPoint.getExtension(ct).isMixin()) 493 .toList(); 494 List<String> onlyContentTypes = contentTypes.stream() 495 .filter(ct -> !_contentTypeExtensionPoint.getExtension(ct).isMixin()) 496 .toList(); 497 if (onlyMixins.isEmpty()) 498 { 499 return new ContentTypeQuery(operator, contentTypes); 500 } 501 else if (onlyContentTypes.isEmpty()) 502 { 503 return new MixinTypeQuery(operator, contentTypes); 504 } 505 else 506 { 507 return new OrQuery(new ContentTypeQuery(operator, onlyContentTypes), new MixinTypeQuery(operator, onlyMixins)); 508 } 509 } 510 511 /** 512 * Retrieves the criterion with the given name 513 * @param criteria the criteria 514 * @param criterionName the name of the searched criterion 515 * @return the criterion with the given name, or <code>null</code> if no corresponding criterion has been found 516 */ 517 public static ModelViewItem getCriterion(ViewItemContainer criteria, String criterionName) 518 { 519 if (criteria != null) 520 { 521 for (ViewItem viewItem : criteria.getViewItems()) 522 { 523 if (viewItem instanceof ModelViewItem modelViewItem 524 && modelViewItem.getDefinition() instanceof CriterionDefinition criterion 525 && criterionName.equals(criterion.getName())) 526 { 527 return modelViewItem; 528 } 529 530 if (viewItem instanceof ViewItemContainer itemContainer) 531 { 532 ModelViewItem criterion = getCriterion(itemContainer, criterionName); 533 if (criterion != null) 534 { 535 return criterion; 536 } 537 } 538 } 539 } 540 541 // No corresponding criterion has been found 542 return null; 543 } 544}