001/* 002 * Copyright 2020 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.plugins.mobileapp; 017 018import java.time.Instant; 019import java.time.LocalDate; 020import java.time.ZoneId; 021import java.time.ZonedDateTime; 022import java.util.Collections; 023import java.util.Date; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.Iterator; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030import java.util.stream.Collectors; 031 032import org.apache.avalon.framework.component.Component; 033import org.apache.avalon.framework.service.ServiceException; 034import org.apache.avalon.framework.service.ServiceManager; 035import org.apache.avalon.framework.service.Serviceable; 036import org.apache.commons.lang3.StringUtils; 037 038import org.ametys.cms.contenttype.ContentTypesHelper; 039import org.ametys.cms.data.ExplorerFile; 040import org.ametys.cms.repository.Content; 041import org.ametys.cms.search.SortOrder; 042import org.ametys.cms.search.content.ContentSearcherFactory; 043import org.ametys.cms.search.content.ContentSearcherFactory.ContentSearchSort; 044import org.ametys.cms.search.content.ContentSearcherFactory.SearchModelContentSearcher; 045import org.ametys.cms.search.ui.model.SearchUIModel; 046import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint; 047import org.ametys.cms.transformation.xslt.ResolveURIComponent; 048import org.ametys.core.util.DateUtils; 049import org.ametys.plugins.queriesdirectory.Query; 050import org.ametys.plugins.queriesdirectory.QueryContainer; 051import org.ametys.plugins.repository.AmetysObject; 052import org.ametys.plugins.repository.AmetysObjectIterable; 053import org.ametys.plugins.repository.AmetysObjectResolver; 054import org.ametys.runtime.config.Config; 055import org.ametys.runtime.model.ModelItem; 056import org.ametys.runtime.model.exception.UndefinedItemPathException; 057import org.ametys.runtime.model.type.ModelItemTypeConstants; 058import org.ametys.runtime.plugin.component.AbstractLogEnabled; 059import org.ametys.web.repository.content.WebContent; 060import org.ametys.web.repository.page.Page; 061import org.ametys.web.repository.site.Site; 062import org.ametys.web.repository.site.SiteManager; 063 064import com.google.common.collect.ImmutableList; 065 066/** 067 * Manager to list and execute queries 068 */ 069public class QueriesHelper extends AbstractLogEnabled implements Serviceable, Component 070{ 071 /** Avalon Role */ 072 public static final String ROLE = QueriesHelper.class.getName(); 073 074 /** global configuration about queries */ 075 public static final String QUERY_CONTAINER_CONF_ID = "plugin.mobileapp.query.container"; 076 077 /** site configuration about queries */ 078 public static final String QUERY_CONTAINER_SITE_CONF_ID = "mobileapp-query-container"; 079 080 /** global configuration about queries limit */ 081 public static final String QUERY_LIMIT_CONF_ID = "plugin.mobileapp.query.limit"; 082 083 /** The service manager */ 084 protected ServiceManager _manager; 085 086 /** The Ametys object resolver */ 087 private AmetysObjectResolver _resolver; 088 089 /** Queries Helper */ 090 private org.ametys.plugins.queriesdirectory.QueryHelper _queriesHelper; 091 092 /** SearchUI Model Extension Point */ 093 private SearchUIModelExtensionPoint _searchUiEP; 094 095 /** The helper to handler content types */ 096 private ContentTypesHelper _contentTypesHelper; 097 098 /** Content Searcher Factory */ 099 private ContentSearcherFactory _contentSearcherFactory; 100 101 private SiteManager _siteManager; 102 103 public void service(ServiceManager manager) throws ServiceException 104 { 105 _manager = manager; 106 } 107 108 /** 109 * Getter for AmetysObjectResolver to avoid problems at startup 110 * @return AmetysObjectResolver 111 */ 112 protected AmetysObjectResolver getResolver() 113 { 114 if (_resolver == null) 115 { 116 try 117 { 118 _resolver = (AmetysObjectResolver) _manager.lookup(AmetysObjectResolver.ROLE); 119 } 120 catch (ServiceException e) 121 { 122 getLogger().error("Error while retrieving AmetysObjectResolver", e); 123 } 124 } 125 return _resolver; 126 } 127 128 /** 129 * Getter for QueriesManager to avoid problems at startup 130 * @return QueriesManager 131 */ 132 protected org.ametys.plugins.queriesdirectory.QueryHelper getQueriesManager() 133 { 134 if (_queriesHelper == null) 135 { 136 try 137 { 138 _queriesHelper = (org.ametys.plugins.queriesdirectory.QueryHelper) _manager.lookup(org.ametys.plugins.queriesdirectory.QueryHelper.ROLE); 139 } 140 catch (ServiceException e) 141 { 142 getLogger().error("Error while retrieving QueriesManager", e); 143 } 144 } 145 return _queriesHelper; 146 } 147 148 /** 149 * Getter for SearchUIModelExtensionPoint to avoid problems at startup 150 * @return SearchUIModelExtensionPoint 151 */ 152 protected SearchUIModelExtensionPoint getSearchUIModelExtensionPoint() 153 { 154 if (_searchUiEP == null) 155 { 156 try 157 { 158 _searchUiEP = (SearchUIModelExtensionPoint) _manager.lookup(SearchUIModelExtensionPoint.ROLE); 159 } 160 catch (ServiceException e) 161 { 162 getLogger().error("Error while retrieving SearchUIModelExtensionPoint", e); 163 } 164 } 165 return _searchUiEP; 166 } 167 168 /** 169 * Getter for ContentTypesHelper to avoid problems at startup 170 * @return ContentTypesHelper 171 */ 172 protected ContentTypesHelper getContentTypesHelper() 173 { 174 if (_contentTypesHelper == null) 175 { 176 try 177 { 178 _contentTypesHelper = (ContentTypesHelper) _manager.lookup(ContentTypesHelper.ROLE); 179 } 180 catch (ServiceException e) 181 { 182 getLogger().error("Error while retrieving ContentTypesHelper", e); 183 } 184 } 185 return _contentTypesHelper; 186 } 187 188 /** 189 * Getter for SiteManager to avoid problems at startup 190 * @return SiteManager 191 */ 192 protected SiteManager getSiteManager() 193 { 194 if (_siteManager == null) 195 { 196 try 197 { 198 _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE); 199 } 200 catch (ServiceException e) 201 { 202 getLogger().error("Error while retrieving SiteManager", e); 203 } 204 } 205 return _siteManager; 206 } 207 208 /** 209 * Getter for ContentSearcherFactory to avoid problems at startup 210 * @return ContentSearcherFactory 211 */ 212 protected ContentSearcherFactory getContentSearcherFactory() 213 { 214 if (_contentSearcherFactory == null) 215 { 216 try 217 { 218 _contentSearcherFactory = (ContentSearcherFactory) _manager.lookup(ContentSearcherFactory.ROLE); 219 } 220 catch (ServiceException e) 221 { 222 getLogger().error("Error while retrieving SiteManager", e); 223 } 224 } 225 return _contentSearcherFactory; 226 } 227 228 /** 229 * List all queries available for the mobile app 230 * @param site the site. May be null 231 * @return a list of {@link Query} 232 */ 233 public List<Query> getQueries(Site site) 234 { 235 String queryContainerId = site != null ? site.getValue(QUERY_CONTAINER_SITE_CONF_ID) : null; 236 237 if (StringUtils.isBlank(queryContainerId)) 238 { 239 queryContainerId = Config.getInstance().getValue(QUERY_CONTAINER_CONF_ID); 240 } 241 242 AmetysObject ametysObject = getResolver().resolveById(queryContainerId); 243 return getQueries(ametysObject); 244 } 245 246 /** 247 * Get all queries available in a specific {@link QueryContainer} 248 * @param ametysObject a {@link QueryContainer} to parse, or directly a {@link Query} to return in a list 249 * @return a list containing all {@link Query} available in this {@link QueryContainer} 250 */ 251 public List<Query> getQueries(AmetysObject ametysObject) 252 { 253 getLogger().info("Fetch queries under query '{}'", ametysObject.getId()); 254 if (ametysObject instanceof QueryContainer) 255 { 256 QueryContainer queryContainer = (QueryContainer) ametysObject; 257 258 return queryContainer.getChildren().stream() 259 .map(q -> getQueries(q)) 260 .flatMap(List::stream) 261 .collect(Collectors.toList()); 262 } 263 else if (ametysObject instanceof Query) 264 { 265 getLogger().info("Query '{}' is not a container", ametysObject.getId()); 266 return ImmutableList.of((Query) ametysObject); 267 } 268 else 269 { 270 getLogger().info("Query '{}' is empty", ametysObject.getId()); 271 return Collections.EMPTY_LIST; 272 } 273 } 274 275 /** 276 * Execute a query, with a forced sort 277 * @param query the query to execute 278 * @param sort a list of sert elements, can contain lastValidation 279 * @param checkRights check the rights while executing the query 280 * @return the results of the query 281 */ 282 public AmetysObjectIterable<Content> executeQuery(Query query, List<ContentSearchSort> sort, boolean checkRights) 283 { 284 getLogger().info("Execute query '{}', testing rights : '{}'", query.getId(), checkRights); 285 try 286 { 287 Long limit = Config.getInstance().getValue(QUERY_LIMIT_CONF_ID); 288 Map<String, Object> exportParams = getQueriesManager().getExportParams(query); 289 290 String model = getQueriesManager().getModelForQuery(exportParams); 291 292 if ("solr".equals(query.getType())) 293 { 294 String queryStr = getQueriesManager().getQueryStringForQuery(exportParams); 295 Set<String> contentTypeIds = getQueriesManager().getContentTypesForQuery(exportParams).stream().collect(Collectors.toSet()); 296 AmetysObjectIterable<Content> results = getContentSearcherFactory().create(contentTypeIds) 297 .setCheckRights(checkRights) 298 .withSort(sort) 299 .withLimits(0, limit.intValue()) 300 .search(queryStr); 301 getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize()); 302 return results; 303 } 304 else if (Query.Type.SIMPLE.toString().equals(query.getType()) || Query.Type.ADVANCED.toString().equals(query.getType())) 305 { 306 Map<String, Object> values = getQueriesManager().getValuesForQuery(exportParams); 307 308 Map<String, Object> contextualParameters = getQueriesManager().getContextualParametersForQuery(exportParams); 309 String searchMode = getQueriesManager().getSearchModeForQuery(exportParams); 310 SearchUIModel uiModel = getSearchUIModelExtensionPoint().getExtension(model); 311 SearchUIModel wrappedUiModel = new SearchModelWrapper(uiModel, _manager, getLogger()); 312 313 SearchModelContentSearcher searcher = getContentSearcherFactory().create(wrappedUiModel); 314 AmetysObjectIterable<Content> results = searcher 315 .setCheckRights(checkRights) 316 .withLimits(0, limit.intValue()) 317 .withSort(sort) 318 .withSearchMode(searchMode) 319 .search(values, contextualParameters); 320 getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize()); 321 return results; 322 } 323 } 324 catch (Exception e) 325 { 326 getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e); 327 } 328 return null; 329 } 330 331 /** 332 * Execute all queries to return the list of the queries that return this object 333 * @param content the object to test 334 * @param site the site. May be null 335 * @return a list containing all the impacted queries 336 */ 337 public Set<Query> getQueriesForResult(AmetysObject content, Site site) 338 { 339 return getQueriesForResult(content.getId(), site); 340 } 341 342 /** 343 * Execute all queries to return the list of the queries that return this object 344 * @param contentId the content id 345 * @param site the site. May be null 346 * @return a list containing all the impacted queries 347 */ 348 public Set<Query> getQueriesForResult(String contentId, Site site) 349 { 350 Map<String, Set<Query>> queriesForResult = getQueriesForResult(List.of(contentId), site); 351 if (queriesForResult.containsKey(contentId)) 352 { 353 return queriesForResult.get(contentId); 354 } 355 else 356 { 357 return Collections.EMPTY_SET; 358 } 359 } 360 361 /** 362 * Execute all queries to return the list of the queries that return this object 363 * @param contents a list of object to test 364 * @param site the site. May be null 365 * @return a map containing, for each content, the list containing all the impacted queries 366 */ 367 public Map<String, Set<Query>> getQueriesForResult(List<String> contents, Site site) 368 { 369 Map<String, Set<Query>> result = new HashMap<>(); 370 List<Query> queries = getQueries(site); 371 372 getLogger().info("Searching for queries returning contents : {}", contents); 373 for (Query query : queries) 374 { 375 getLogger().info("Searching with query : {}", query.getId()); 376 try 377 { 378 List<ContentSearchSort> sort = getSortProperty(query, queries.size() > 1); 379 380 AmetysObjectIterable<Content> searchResults = executeQuery(query, sort, false); 381 if (searchResults != null) 382 { 383 List<String> resultIds = searchResults.stream().map(c -> c.getId()).collect(Collectors.toList()); 384 385 for (String resultId : resultIds) 386 { 387 if (contents.contains(resultId)) 388 { 389 getLogger().info("Query : {} contains content : {}", query.getId(), resultId); 390 if (!result.containsKey(resultId)) 391 { 392 result.put(resultId, new HashSet<>()); 393 } 394 result.get(resultId).add(query); 395 } 396 } 397 } 398 } 399 catch (Exception e) 400 { 401 getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e); 402 } 403 } 404 if (result.isEmpty()) 405 { 406 getLogger().info("No query found for contents '{}'", contents); 407 } 408 return result; 409 } 410 411 /** 412 * Generate a list of sort properties. 413 * If there are multiple queries, they have to be sorted first by date ASC. If they already are, the sort is used, otherwise a sort on lastValidation is used 414 * @param query the query that will be executed 415 * @param moreThanOne if false and the query already contain a sort, the original sort will be returned 416 * @return a list of sort, with the first one by date if moreThanOne is true 417 */ 418 public List<ContentSearchSort> getSortProperty(Query query, boolean moreThanOne) 419 { 420 Map<String, Object> exportParams = getQueriesManager().getExportParams(query); 421 List<ContentSearchSort> baseSort = getQueriesManager().getSortForQuery(exportParams); 422 if (!moreThanOne && baseSort != null && !baseSort.isEmpty()) 423 { 424 return baseSort; 425 } 426 else 427 { 428 if (baseSort != null && !baseSort.isEmpty()) 429 { 430 ContentSearchSort firstSort = baseSort.get(0); 431 Set<String> specificFields = Set.of("creationDate", "lastValidation", "lastModified"); 432 if (specificFields.contains(firstSort.sortField())) 433 { 434 return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC)); 435 } 436 437 List<String> contentTypesForQuery = getQueriesManager().getContentTypesForQuery(exportParams); 438 if (contentTypesForQuery != null) 439 { 440 String[] contentTypeIdsAsArray = contentTypesForQuery.toArray(new String[contentTypesForQuery.size()]); 441 try 442 { 443 ModelItem modelItem = getContentTypesHelper().getModelItem(firstSort.sortField(), contentTypeIdsAsArray, new String[0]); 444 445 String sortFileTypeId = modelItem.getType().getId(); 446 if (ModelItemTypeConstants.DATE_TYPE_ID.equals(sortFileTypeId) || ModelItemTypeConstants.DATETIME_TYPE_ID.equals(sortFileTypeId)) 447 { 448 return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC)); 449 } 450 } 451 catch (UndefinedItemPathException e) 452 { 453 getLogger().warn("Error while fetching the model of the field '" + firstSort.sortField() + "' for content types [" + String.join(", ", contentTypesForQuery) + "]", e); 454 } 455 } 456 } 457 ContentSearchSort sort = new ContentSearchSort("lastValidation", SortOrder.ASC); 458 return List.of(sort); 459 } 460 } 461 462 /** 463 * Get a json object representing the content 464 * @param content tho content to parse 465 * @return a json map 466 */ 467 public Map<String, String> getDataForContent(Content content) 468 { 469 Map<String, String> result = new HashMap<>(); 470 // feed_id 471 // category_name 472 // date 473 // content_id 474 result.put("content_id", content.getId()); 475 if (content.hasValue("illustration/image")) 476 { 477 String image = null; 478 479 Object value = content.getValue("illustration/image"); 480 if (value instanceof ExplorerFile file) 481 { 482 image = ResolveURIComponent.resolveCroppedImage("explorer", file.getResourceId(), PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true); 483 } 484 else 485 { 486 String imgUri = "illustration/image?contentId=" + content.getId(); 487 image = ResolveURIComponent.resolveCroppedImage("attribute", imgUri, PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true); 488 } 489 490 if (image != null) 491 { 492 result.put("image", image); 493 } 494 } 495 // title 496 result.put("title", content.getTitle()); 497 // content_url 498 if (content instanceof WebContent) 499 { 500 WebContent webContent = (WebContent) content; 501 Iterator<Page> pages = webContent.getReferencingPages().iterator(); 502 if (pages.hasNext()) 503 { 504 Page page = pages.next(); 505 String siteName = webContent.getSiteName(); 506 String url = getSiteManager().getSite(siteName).getUrl() + "/" + page.getSitemap().getName() + "/" + page.getPathInSitemap() + ".html"; 507 result.put("content_url", url); 508 } 509 510 } 511 return result; 512 } 513 514 /** 515 * Transform a ZonedDateTime, LocalDate or Date to an instant 516 * @param o1 the date to transform 517 * @param o2 a relative date, if it contains a timezone and the 1st does not, this timezone will bu used 518 * @return an Instant 519 */ 520 public Instant toInstant(Object o1, Object o2) 521 { 522 if (o1 instanceof ZonedDateTime) 523 { 524 return ((ZonedDateTime) o1).toInstant(); 525 } 526 else if (o1 instanceof LocalDate) 527 { 528 if (o2 instanceof ZonedDateTime) 529 { 530 LocalDate ld1 = (LocalDate) o1; 531 ZonedDateTime zdt2 = (ZonedDateTime) o2; 532 return ld1.atStartOfDay(zdt2.getZone()).toInstant(); 533 } 534 else 535 { 536 return ((LocalDate) o1).atStartOfDay(ZoneId.systemDefault()).toInstant(); 537 } 538 } 539 else if (o1 instanceof Date) 540 { 541 return ((Date) o1).toInstant(); 542 } 543 544 return null; 545 } 546 547 /** 548 * Get the date of the content, can be the date requested in the field, or by default the last validation date 549 * @param content the content to read 550 * @param field the field to check 551 * @return the iso formatted date 552 */ 553 public String getContentFormattedDate(Content content, String field) 554 { 555 String isoDate = null; 556 if (StringUtils.isNotBlank(field) && content.hasValue(field)) 557 { 558 Object value = content.getValue(field, true, null); 559 if (value instanceof Date) 560 { 561 isoDate = DateUtils.dateToString((Date) value); 562 } 563 else 564 { 565 Instant instant = toInstant(value, null); 566 if (instant != null) 567 { 568 Date date = new Date(instant.toEpochMilli()); 569 isoDate = DateUtils.dateToString(date); 570 } 571 } 572 } 573 else if ("lastModified".equals(field)) 574 { 575 // lastModified is not a content field 576 isoDate = DateUtils.zonedDateTimeToString(content.getLastModified()); 577 } 578 579 // If no date found, use the last validation date 580 if (StringUtils.isBlank(isoDate)) 581 { 582 isoDate = DateUtils.zonedDateTimeToString(content.getLastValidationDate()); 583 } 584 585 return isoDate; 586 } 587}