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