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