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 if (StringUtils.isBlank(queryContainerId)) 248 { 249 return List.of(); 250 } 251 252 AmetysObject ametysObject = getResolver().resolveById(queryContainerId); 253 Stream<Query> queries = getQueries(ametysObject); 254 255 if (notificationOnly) 256 { 257 return queries.toList(); 258 } 259 260 queryContainerId = site != null ? site.getValue(QUERY_FEED_CONTAINER_SITE_CONF_ID) : null; 261 262 if (StringUtils.isBlank(queryContainerId)) 263 { 264 queryContainerId = Config.getInstance().getValue(QUERY_FEED_CONTAINER_CONF_ID); 265 } 266 267 if (StringUtils.isNotBlank(queryContainerId)) 268 { 269 ametysObject = getResolver().resolveById(queryContainerId); 270 return Stream.concat(queries, getQueries(ametysObject)).distinct().toList(); 271 } 272 273 return queries.toList(); 274 } 275 276 /** 277 * Get all queries available in a specific {@link QueryContainer} 278 * @param ametysObject a {@link QueryContainer} to parse, or directly a {@link Query} to return in a list 279 * @return a list containing all {@link Query} available in this {@link QueryContainer} 280 */ 281 public Stream<Query> getQueries(AmetysObject ametysObject) 282 { 283 getLogger().info("Fetch queries under query '{}'", ametysObject.getId()); 284 if (ametysObject instanceof QueryContainer) 285 { 286 QueryContainer queryContainer = (QueryContainer) ametysObject; 287 288 return queryContainer.getChildren().stream() 289 .flatMap(q -> getQueries(q)); 290 } 291 else if (ametysObject instanceof Query) 292 { 293 getLogger().info("Query '{}' is not a container", ametysObject.getId()); 294 return Stream.of((Query) ametysObject); 295 } 296 else 297 { 298 getLogger().info("Query '{}' is empty", ametysObject.getId()); 299 return Stream.of(); 300 } 301 } 302 303 /** 304 * Execute a query, with a forced sort 305 * @param query the query to execute 306 * @param sort a list of sort elements, can contain lastValidation 307 * @param checkRights check the rights while executing the query 308 * @param limit the max number of result, -1 for no limit 309 * @return the results of the query 310 */ 311 public AmetysObjectIterable<Content> executeQuery(Query query, List<ContentSearchSort> sort, boolean checkRights, int limit) 312 { 313 getLogger().info("Execute query '{}', testing rights : '{}'", query.getId(), checkRights); 314 try 315 { 316 Map<String, Object> exportParams = getQueriesManager().getExportParams(query); 317 318 String model = getQueriesManager().getModelForQuery(exportParams); 319 320 if ("solr".equals(query.getType())) 321 { 322 String queryStr = getQueriesManager().getQueryStringForQuery(exportParams); 323 Set<String> contentTypeIds = getQueriesManager().getContentTypesForQuery(exportParams).stream().collect(Collectors.toSet()); 324 SimpleContentSearcher searcher = getContentSearcherFactory().create(contentTypeIds) 325 .setCheckRights(checkRights) 326 .withSort(sort); 327 if (limit != -1) 328 { 329 searcher.withLimits(0, limit); 330 } 331 AmetysObjectIterable<Content> results = searcher.search(queryStr); 332 getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize()); 333 return results; 334 } 335 else if (Query.Type.SIMPLE.toString().equals(query.getType()) || Query.Type.ADVANCED.toString().equals(query.getType())) 336 { 337 Map<String, Object> values = getQueriesManager().getValuesForQuery(exportParams); 338 339 Map<String, Object> contextualParameters = getQueriesManager().getContextualParametersForQuery(exportParams); 340 String searchMode = getQueriesManager().getSearchModeForQuery(exportParams); 341 SearchUIModel uiModel = getSearchUIModelExtensionPoint().getExtension(model); 342 SearchUIModel wrappedUiModel = new SearchModelWrapper(uiModel, _manager, getLogger()); 343 344 SearchModelContentSearcher searcher = getContentSearcherFactory().create(wrappedUiModel) 345 .withSort(sort) 346 .withSearchMode(searchMode) 347 .setCheckRights(checkRights); 348 if (limit != -1) 349 { 350 searcher.withLimits(0, limit); 351 } 352 AmetysObjectIterable<Content> results = searcher 353 .search(values, contextualParameters); 354 getLogger().info("Query '{}' returned {} results", query.getId(), results.getSize()); 355 return results; 356 } 357 } 358 catch (Exception e) 359 { 360 getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e); 361 } 362 return null; 363 } 364 365 /** 366 * Execute all queries to return the list of the queries that return this object 367 * @param content the object to test 368 * @param site the site. May be null 369 * @param notificationOnly true to only retrieve queries used for notification 370 * @param limit the max number of result for each query, -1 for no limit 371 * @return a list containing all the impacted queries 372 */ 373 public Set<Query> getQueriesForResult(AmetysObject content, Site site, boolean notificationOnly, int limit) 374 { 375 return getQueriesForResult(content.getId(), site, notificationOnly, limit); 376 } 377 378 /** 379 * Execute all queries to return the list of the queries that return this object 380 * @param contentId the content id 381 * @param site the site. May be null 382 * @param notificationOnly true to only retrieve queries used for notification 383 * @param limit the max number of result for each query, -1 for no limit 384 * @return a list containing all the impacted queries 385 */ 386 public Set<Query> getQueriesForResult(String contentId, Site site, boolean notificationOnly, int limit) 387 { 388 Map<String, Set<Query>> queriesForResult = getQueriesForResult(List.of(contentId), site, notificationOnly, limit); 389 if (queriesForResult.containsKey(contentId)) 390 { 391 return queriesForResult.get(contentId); 392 } 393 else 394 { 395 return Collections.EMPTY_SET; 396 } 397 } 398 399 /** 400 * Execute all queries to return the list of the queries that return this object 401 * @param contents a list of object to test 402 * @param site the site. May be null 403 * @param notificationOnly true to only retrieve queries used for notification 404 * @param limit the max number of result for each query, -1 for no limit 405 * @return a map containing, for each content, the list containing all the impacted queries 406 */ 407 public Map<String, Set<Query>> getQueriesForResult(List<String> contents, Site site, boolean notificationOnly, int limit) 408 { 409 Map<String, Set<Query>> result = new HashMap<>(); 410 List<Query> queries = getQueries(site, notificationOnly); 411 412 getLogger().info("Searching for queries returning contents : {}", contents); 413 for (Query query : queries) 414 { 415 getLogger().info("Searching with query : {}", query.getId()); 416 try 417 { 418 List<ContentSearchSort> sort = getSortProperty(query, queries.size() > 1); 419 420 AmetysObjectIterable<Content> searchResults = executeQuery(query, sort, false, limit); 421 if (searchResults != null) 422 { 423 List<String> resultIds = searchResults.stream().map(c -> c.getId()).collect(Collectors.toList()); 424 425 for (String resultId : resultIds) 426 { 427 if (contents.contains(resultId)) 428 { 429 getLogger().info("Query : {} contains content : {}", query.getId(), resultId); 430 if (!result.containsKey(resultId)) 431 { 432 result.put(resultId, new HashSet<>()); 433 } 434 result.get(resultId).add(query); 435 } 436 } 437 } 438 } 439 catch (Exception e) 440 { 441 getLogger().error("Error during the execution of the query '" + query.getTitle() + "' (" + query.getId() + ")", e); 442 } 443 } 444 if (result.isEmpty()) 445 { 446 getLogger().info("No query found for contents '{}'", contents); 447 } 448 return result; 449 } 450 451 /** 452 * Generate a list of sort properties. 453 * 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 454 * @param query the query that will be executed 455 * @param moreThanOne if false and the query already contain a sort, the original sort will be returned 456 * @return a list of sort, with the first one by date if moreThanOne is true 457 */ 458 public List<ContentSearchSort> getSortProperty(Query query, boolean moreThanOne) 459 { 460 Map<String, Object> exportParams = getQueriesManager().getExportParams(query); 461 List<ContentSearchSort> baseSort = getQueriesManager().getSortForQuery(exportParams); 462 if (!moreThanOne && baseSort != null && !baseSort.isEmpty()) 463 { 464 return baseSort; 465 } 466 else 467 { 468 if (baseSort != null && !baseSort.isEmpty()) 469 { 470 ContentSearchSort firstSort = baseSort.get(0); 471 Set<String> specificFields = Set.of("creationDate", "lastValidation", "lastModified"); 472 if (specificFields.contains(firstSort.sortField())) 473 { 474 return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC)); 475 } 476 477 List<String> contentTypesForQuery = getQueriesManager().getContentTypesForQuery(exportParams); 478 if (contentTypesForQuery != null) 479 { 480 String[] contentTypeIdsAsArray = contentTypesForQuery.toArray(new String[contentTypesForQuery.size()]); 481 try 482 { 483 ModelItem modelItem = getContentTypesHelper().getModelItem(firstSort.sortField(), contentTypeIdsAsArray, new String[0]); 484 485 String sortFileTypeId = modelItem.getType().getId(); 486 if (ModelItemTypeConstants.DATE_TYPE_ID.equals(sortFileTypeId) || ModelItemTypeConstants.DATETIME_TYPE_ID.equals(sortFileTypeId)) 487 { 488 return List.of(new ContentSearchSort(firstSort.sortField(), SortOrder.ASC)); 489 } 490 } 491 catch (UndefinedItemPathException e) 492 { 493 getLogger().warn("Error while fetching the model of the field '" + firstSort.sortField() + "' for content types [" + String.join(", ", contentTypesForQuery) + "]", e); 494 } 495 } 496 } 497 ContentSearchSort sort = new ContentSearchSort("lastValidation", SortOrder.ASC); 498 return List.of(sort); 499 } 500 } 501 502 /** 503 * Get a json object representing the content 504 * @param content tho content to parse 505 * @return a json map 506 */ 507 public Map<String, String> getDataForContent(Content content) 508 { 509 Map<String, String> result = new HashMap<>(); 510 // feed_id 511 // category_name 512 // date 513 // content_id 514 result.put("content_id", content.getId()); 515 if (content.hasValue("illustration/image")) 516 { 517 String image = null; 518 519 Object value = content.getValue("illustration/image"); 520 if (value instanceof ExplorerFile file) 521 { 522 image = ResolveURIComponent.resolveCroppedImage("explorer", file.getResourceId(), PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true); 523 } 524 else 525 { 526 String imgUri = "illustration/image?contentId=" + content.getId(); 527 image = ResolveURIComponent.resolveCroppedImage("attribute", imgUri, PostConstants.IMAGE_SIZE, PostConstants.IMAGE_SIZE, false, true); 528 } 529 530 if (image != null) 531 { 532 result.put("image", image); 533 } 534 } 535 // title 536 result.put("title", content.getTitle()); 537 // content_url 538 if (content instanceof WebContent) 539 { 540 WebContent webContent = (WebContent) content; 541 Iterator<Page> pages = webContent.getReferencingPages().iterator(); 542 if (pages.hasNext()) 543 { 544 Page page = pages.next(); 545 String siteName = webContent.getSiteName(); 546 String url = getSiteManager().getSite(siteName).getUrl() + "/" + page.getSitemap().getName() + "/" + page.getPathInSitemap() + ".html"; 547 result.put("content_url", url); 548 } 549 550 } 551 return result; 552 } 553 554 /** 555 * Transform a ZonedDateTime, LocalDate or Date to an instant 556 * @param o1 the date to transform 557 * @param o2 a relative date, if it contains a timezone and the 1st does not, this timezone will bu used 558 * @return an Instant 559 */ 560 public Instant toInstant(Object o1, Object o2) 561 { 562 if (o1 instanceof ZonedDateTime) 563 { 564 return ((ZonedDateTime) o1).toInstant(); 565 } 566 else if (o1 instanceof LocalDate) 567 { 568 if (o2 instanceof ZonedDateTime) 569 { 570 LocalDate ld1 = (LocalDate) o1; 571 ZonedDateTime zdt2 = (ZonedDateTime) o2; 572 return ld1.atStartOfDay(zdt2.getZone()).toInstant(); 573 } 574 else 575 { 576 return ((LocalDate) o1).atStartOfDay(ZoneId.systemDefault()).toInstant(); 577 } 578 } 579 else if (o1 instanceof Date) 580 { 581 return ((Date) o1).toInstant(); 582 } 583 584 return null; 585 } 586 587 /** 588 * Get the date of the content, can be the date requested in the field, or by default the last validation date 589 * @param content the content to read 590 * @param field the field to check 591 * @return the iso formatted date 592 */ 593 public String getContentFormattedDate(Content content, String field) 594 { 595 String isoDate = null; 596 if (StringUtils.isNotBlank(field) && content.hasValue(field)) 597 { 598 Object value = content.getValue(field, true, null); 599 if (value instanceof Date) 600 { 601 isoDate = DateUtils.dateToString((Date) value); 602 } 603 else 604 { 605 Instant instant = toInstant(value, null); 606 if (instant != null) 607 { 608 Date date = new Date(instant.toEpochMilli()); 609 isoDate = DateUtils.dateToString(date); 610 } 611 } 612 } 613 else if ("lastModified".equals(field)) 614 { 615 // lastModified is not a content field 616 isoDate = DateUtils.zonedDateTimeToString(content.getLastModified()); 617 } 618 619 // If no date found, use the last validation date 620 if (StringUtils.isBlank(isoDate)) 621 { 622 isoDate = DateUtils.zonedDateTimeToString(content.getLastValidationDate()); 623 } 624 625 return isoDate; 626 } 627}