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