001/* 002 * Copyright 2015 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.web.frontoffice; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.time.ZonedDateTime; 021import java.util.ArrayList; 022import java.util.Arrays; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.Date; 026import java.util.Enumeration; 027import java.util.List; 028import java.util.Locale; 029import java.util.Map; 030import java.util.Map.Entry; 031import java.util.Objects; 032import java.util.Set; 033import java.util.regex.Pattern; 034import java.util.stream.Collectors; 035 036import org.apache.avalon.framework.context.Context; 037import org.apache.avalon.framework.context.ContextException; 038import org.apache.avalon.framework.context.Contextualizable; 039import org.apache.avalon.framework.service.ServiceException; 040import org.apache.avalon.framework.service.ServiceManager; 041import org.apache.cocoon.ProcessingException; 042import org.apache.cocoon.environment.ObjectModelHelper; 043import org.apache.cocoon.environment.Request; 044import org.apache.cocoon.generation.ServiceableGenerator; 045import org.apache.cocoon.xml.AttributesImpl; 046import org.apache.cocoon.xml.XMLUtils; 047import org.apache.commons.lang.StringUtils; 048import org.apache.commons.lang3.LocaleUtils; 049import org.apache.excalibur.xml.sax.SAXParser; 050import org.apache.solr.client.solrj.util.ClientUtils; 051import org.apache.tika.Tika; 052import org.slf4j.Logger; 053import org.xml.sax.InputSource; 054import org.xml.sax.SAXException; 055 056import org.ametys.cms.content.RichTextHandler; 057import org.ametys.cms.contenttype.ContentType; 058import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 059import org.ametys.cms.contenttype.ContentTypesHelper; 060import org.ametys.cms.data.RichText; 061import org.ametys.cms.data.type.ModelItemTypeConstants; 062import org.ametys.cms.repository.Content; 063import org.ametys.cms.search.SearchResults; 064import org.ametys.cms.search.content.ContentSearchHelper; 065import org.ametys.cms.search.query.DocumentTypeQuery; 066import org.ametys.cms.search.query.OrQuery; 067import org.ametys.cms.search.query.Query; 068import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition; 069import org.ametys.cms.search.solr.SearcherFactory.Searcher; 070import org.ametys.cms.search.solr.SearcherFactory.SortDefinition; 071import org.ametys.cms.tag.TagProviderExtensionPoint; 072import org.ametys.cms.transformation.URIResolverExtensionPoint; 073import org.ametys.core.file.TikaProvider; 074import org.ametys.core.util.DateUtils; 075import org.ametys.core.util.FilenameUtils; 076import org.ametys.core.util.LambdaUtils; 077import org.ametys.core.util.URIUtils; 078import org.ametys.plugins.explorer.resources.Resource; 079import org.ametys.plugins.repository.AmetysObject; 080import org.ametys.plugins.repository.AmetysObjectIterable; 081import org.ametys.plugins.repository.AmetysObjectResolver; 082import org.ametys.plugins.repository.AmetysRepositoryException; 083import org.ametys.plugins.repository.UnknownAmetysObjectException; 084import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 085import org.ametys.runtime.i18n.I18nizableText; 086import org.ametys.runtime.model.ElementDefinition; 087import org.ametys.runtime.model.ModelItem; 088import org.ametys.runtime.model.View; 089import org.ametys.runtime.model.ViewHelper; 090import org.ametys.runtime.model.type.DataContext; 091import org.ametys.web.WebConstants; 092import org.ametys.web.frontoffice.FrontOfficeSearcherFactory.QueryFacet; 093import org.ametys.web.indexing.solr.SolrWebFieldNames; 094import org.ametys.web.repository.page.Page; 095import org.ametys.web.repository.page.Page.PageType; 096import org.ametys.web.repository.page.Zone; 097import org.ametys.web.repository.page.ZoneItem; 098import org.ametys.web.repository.page.ZoneItem.ZoneType; 099import org.ametys.web.repository.site.Site; 100import org.ametys.web.repository.site.SiteManager; 101 102/** 103 * Abstract class for solr search 104 */ 105public abstract class AbstractSearchGenerator extends ServiceableGenerator implements Contextualizable, SolrWebFieldNames 106{ 107 /** The name of the facet.query testing the pageResources */ 108 public static final String DOCUMENT_TYPE_IS_PAGE_RESOURCE_FACET_NAME = "isPageResource"; 109 110 /** Textfield pattern */ 111 protected static final Pattern _TEXTFIELD_PATTERN = Pattern.compile("^[^?*].*$"); 112 113 /** The {@link ContentType} manager */ 114 protected ContentTypeExtensionPoint _cTypeExtPt; 115 /** The sites manager */ 116 protected SiteManager _siteManager; 117 /** The cocoon context */ 118 protected org.apache.cocoon.environment.Context _context; 119 /** The tag extension point */ 120 protected TagProviderExtensionPoint _tagExtPt; 121 /** The Ametys resolver */ 122 protected AmetysObjectResolver _resolver; 123 /** The helper to handler content types */ 124 protected ContentTypesHelper _contentTypesHelper; 125 /** The uri resolver extension point */ 126 protected URIResolverExtensionPoint _uriResolverEP; 127 /** The searcher factory */ 128 protected FrontOfficeSearcherFactory _searcherFactory; 129 /** The content searcher */ 130 protected ContentSearchHelper _searchHelper; 131 132 @Override 133 public void service(ServiceManager smanager) throws ServiceException 134 { 135 super.service(smanager); 136 _cTypeExtPt = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE); 137 _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE); 138 _tagExtPt = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE); 139 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 140 _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE); 141 _uriResolverEP = (URIResolverExtensionPoint) smanager.lookup(URIResolverExtensionPoint.ROLE); 142 _searcherFactory = (FrontOfficeSearcherFactory) smanager.lookup(FrontOfficeSearcherFactory.ROLE); 143 _searchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE); 144 } 145 146 @Override 147 public void contextualize(Context context) throws ContextException 148 { 149 _context = (org.apache.cocoon.environment.Context) context.get(org.apache.cocoon.Constants.CONTEXT_ENVIRONMENT_CONTEXT); 150 } 151 152 @Override 153 public void generate() throws IOException, SAXException, ProcessingException 154 { 155 Request request = ObjectModelHelper.getRequest(objectModel); 156 157 String currentSiteName = null; 158 String lang = null; 159 Page page = (Page) request.getAttribute(WebConstants.REQUEST_ATTR_PAGE); 160 if (page != null) 161 { 162 currentSiteName = page.getSiteName(); 163 lang = page.getSitemapName(); 164 } 165 else 166 { 167 currentSiteName = parameters.getParameter("siteName", request.getParameter("siteName")); 168 lang = parameters.getParameter("lang", request.getParameter("lang")); 169 } 170 171 int pageIndex = getPageIndex(request); 172 // TODO Rename to maxResults 173 int maxResults = parameters.getParameterAsInteger("offset", 10); 174 int start = (pageIndex - 1) * maxResults; 175 176 String[] sites = request.getParameterValues("sites"); 177 List<String> siteNames = new ArrayList<>(); 178 if (sites != null && sites.length > 0 && !(sites.length == 1 && sites[0].equals(""))) 179 { 180 for (String site : sites) 181 { 182 siteNames.add(site); 183 } 184 } 185 else 186 { 187 siteNames.add(currentSiteName); 188 } 189 190 contentHandler.startDocument(); 191 192 AttributesImpl attrs = new AttributesImpl(); 193 attrs.addCDATAAttribute("site", currentSiteName); 194 attrs.addCDATAAttribute("lang", lang); 195 196 XMLUtils.startElement(contentHandler, "search", attrs); 197 198 saxServiceIdentifiers(); 199 saxAdditionalInfos(); 200 201 // The search url 202 XMLUtils.createElement(contentHandler, "url", page != null ? lang + "/" + page.getPathInSitemap() + ".html" : lang + "/_plugins/" + currentSiteName + "/" + lang + "/service/search-pages.html"); 203 204 // Display the form and results on same page? 205 String searchMode = getSearchMode(); 206 XMLUtils.createElement(contentHandler, "search-mode", searchMode); 207 208 try 209 { 210 SearchResults<AmetysObject> searchResults = null; 211 boolean submit = request.getParameter("submit-form") != null; 212 boolean criteriaOnly = "criteria-only".equals(getSearchMode()); 213 if (submit && isInputValid() && !criteriaOnly) 214 { 215 searchResults = search(request, siteNames, lang, pageIndex, start, maxResults); 216 } 217 else if (!getFacets(request).isEmpty()) 218 { 219 searchResults = search(request, siteNames, lang, pageIndex, start, maxResults, false); 220 } 221 222 saxFormParameters(request, searchResults, start, maxResults, currentSiteName, lang); 223 } 224 catch (IllegalArgumentException e) 225 { 226 getLogger().error("The search field is invalid", e); 227 XMLUtils.createElement(contentHandler, "illegal-textfield"); 228 saxPagination(0, start, maxResults); 229 } 230 catch (Exception e) 231 { 232 getLogger().error("Unable to search", e); 233 saxPagination(0, start, maxResults); 234 } 235 236 XMLUtils.endElement(contentHandler, "search"); 237 contentHandler.endDocument(); 238 } 239 240 /** 241 * Get the zone item 242 * @param request The request 243 * @return the zone item 244 */ 245 protected ZoneItem getZoneItem(Request request) 246 { 247 ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM); 248 if (zoneItem != null) 249 { 250 return zoneItem; 251 } 252 253 String zoneItemId = parameters.getParameter("zoneItemId", request.getParameter("zone-item-id")); 254 if (StringUtils.isNotEmpty(zoneItemId)) 255 { 256 try 257 { 258 return _resolver.resolveById(zoneItemId); 259 } 260 catch (UnknownAmetysObjectException e) 261 { 262 return null; 263 } 264 } 265 266 return null; 267 } 268 269 /** 270 * Search 271 * @param request the request 272 * @param siteNames The name of the sites to search in 273 * @param language The language code to search 274 * @param pageIndex the page index 275 * @param start The offset for search results 276 * @param maxResults The maximum number of results 277 * @return The search results 278 * @throws Exception If an error occurred during search 279 */ 280 protected SearchResults<AmetysObject> search(Request request, Collection<String> siteNames, String language, int pageIndex, int start, int maxResults) throws Exception 281 { 282 return search(request, siteNames, language, pageIndex, start, maxResults, true); 283 } 284 285 /** 286 * Search 287 * @param request the request 288 * @param siteNames The name of the sites to search in 289 * @param language The language code to search 290 * @param pageIndex the page index 291 * @param start The offset for search results 292 * @param maxResults The maximum number of results 293 * @param saxResults false to not sax results 294 * @return The search results 295 * @throws Exception If an error occurred during search 296 */ 297 protected SearchResults<AmetysObject> search(Request request, Collection<String> siteNames, String language, int pageIndex, int start, int maxResults, boolean saxResults) throws Exception 298 { 299 // Retrieve current workspace 300 String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 301 302 SearchResults<AmetysObject> results = null; 303 304 try 305 { 306 // Query 307 Query queryObject = getQuery(request, siteNames, language); 308 309 // Filter queries 310 Collection<Query> filterQueries = getFilterQueries(request, siteNames, language); 311 // Document types query 312 Collection<String> documentTypes = getDocumentTypes(request); 313 Query documentTypesQuery = getDocumentTypesQuery(documentTypes); 314 315 // Get first sort field 316 SortDefinition sort = getSortDefinition(request); 317 saxSort(sort); 318 319 Searcher searcher = _searcherFactory.create() 320 .withQuery(queryObject) 321 .withFilterQueries(filterQueries) 322 .addFilterQuery(documentTypesQuery) 323 .withLimits(0, Integer.MAX_VALUE) 324 .withSort(getPrimarySortFields(request)) 325 .addSort(sort) 326 .setCheckRights(_checkRights()); 327 328 _additionalSearchProcessing(searcher); 329 330 // Facets 331 Collection<FacetDefinition> facets = getFacets(request).values().stream().map(f -> f.getFacetDefinition()).collect(Collectors.toList()); 332 if (!facets.isEmpty()) 333 { 334 searcher.withFacets(facets) 335 .withFacetValues(getFacetValues(request, siteNames, language)); 336 } 337 338 try 339 { 340 results = searcher.searchWithFacets(); 341 } 342 catch (Exception e) 343 { 344 getLogger().error("An error occured with Solr query", e); 345 } 346 347 if (saxResults) 348 { 349 // SAX results 350 AttributesImpl atts = new AttributesImpl(); 351 long total = results != null ? results.getResults().getSize() : 0; 352 atts.addCDATAAttribute("total", String.valueOf(total)); 353 XMLUtils.startElement(contentHandler, "hits", atts); 354 if (results != null) 355 { 356 saxHits(results, start, maxResults); 357 } 358 XMLUtils.endElement(contentHandler, "hits"); 359 360 // SAX pagination 361 saxPagination(total, start, maxResults); 362 } 363 } 364 finally 365 { 366 // Restore context 367 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp); 368 } 369 370 return results; 371 } 372 373 /** 374 * SAX sort 375 * @param sort the current sort 376 * @throws SAXException SAXException If an error occurs while SAXing 377 */ 378 protected void saxSort(SortDefinition sort) throws SAXException 379 { 380 XMLUtils.createElement(contentHandler, sort == null || sort.solrSortFieldName() == null ? "sort-by-score" : "sort-by-" + sort.solrSortFieldName()); 381 } 382 383 /** 384 * Allow to perform other searcher configuration 385 * @param searcher The searcher that will be used for the search operation 386 */ 387 protected void _additionalSearchProcessing(Searcher searcher) 388 { 389 // Nothing by default 390 } 391 392 /** 393 * Build the search query to have results matching at least one of the given document types 394 * @param documentTypes The document types 395 * @return The document type query 396 */ 397 protected Query getDocumentTypesQuery(Collection<String> documentTypes) 398 { 399 List<Query> queries = documentTypes.stream() 400 .map(t -> "\"" + ClientUtils.escapeQueryChars(t) + "\"") 401 .map(DocumentTypeQuery::new) 402 .collect(Collectors.toList()); 403 return new OrQuery(queries); 404 } 405 406 /** 407 * Get the document types. 408 * @param request The request. 409 * @return the document types to search. 410 */ 411 protected abstract Collection<String> getDocumentTypes(Request request); 412 413 /** 414 * Get the sort definition 415 * @param request The request 416 * @return The sort definition or null to sort by score 417 */ 418 protected abstract SortDefinition getSortDefinition(Request request); 419 420 /** 421 * Get the primary sort fields 422 * @param request The request 423 * @return the list additional sort fields or empty list. 424 */ 425 protected abstract List<SortDefinition> getPrimarySortFields(Request request); 426 427 /** 428 * SAX the search parameters from the request parameters 429 * @param request The request 430 * @param searchResults The search results 431 * @param start The start index 432 * @param offset The number of results 433 * @param siteName The current site name 434 * @param lang The current language 435 * @throws SAXException If an error occurs while SAXing 436 */ 437 protected void saxFormParameters (Request request, SearchResults<AmetysObject> searchResults, int start, int offset, String siteName, String lang) throws SAXException 438 { 439 XMLUtils.startElement(contentHandler, "form"); 440 441 XMLUtils.startElement(contentHandler, "fields"); 442 saxFormFields(request, siteName, lang); 443 XMLUtils.endElement(contentHandler, "fields"); 444 445 saxFacets(request, searchResults, siteName, lang); 446 447 boolean submit = request.getParameter("submit-form") != null; 448 if (submit) 449 { 450 if (isInputValid()) 451 { 452 XMLUtils.startElement(contentHandler, "values"); 453 454 XMLUtils.createElement(contentHandler, "start", String.valueOf(start)); 455 XMLUtils.createElement(contentHandler, "offset", String.valueOf(offset)); 456 457 saxFormValues(request, start, offset); 458 459 XMLUtils.endElement(contentHandler, "values"); 460 } 461 } 462 XMLUtils.endElement(contentHandler, "form"); 463 } 464 465 /** 466 * SAX the form criteria 467 * @param request The request 468 * @param siteName The current site name 469 * @param lang The current language 470 * @throws SAXException if an error occurs while SAXing 471 */ 472 protected abstract void saxFormFields(Request request, String siteName, String lang) throws SAXException; 473 474 /** 475 * SAX the facets results 476 * @param request The request 477 * @param searchResults The search result 478 * @param siteName The site name 479 * @param lang The language 480 * @throws SAXException if an error occurred while saxing 481 */ 482 protected void saxFacets(Request request, SearchResults<AmetysObject> searchResults, String siteName, String lang) throws SAXException 483 { 484 XMLUtils.startElement(contentHandler, "facets"); 485 486 Map<String, Map<String, Integer>> facetResults = searchResults != null ? searchResults.getFacetResults() : Collections.EMPTY_MAP; 487 if (!facetResults.isEmpty()) 488 { 489 Map<String, FacetField> facets = getFacets(request); 490 491 for (String fieldName : facets.keySet()) 492 { 493 if (facetResults.containsKey(SolrWebFieldNames.FACETABLE_CONTENT_FIELD_PREFIX + fieldName)) 494 { 495 Map<String, Integer> values = facetResults.get(SolrWebFieldNames.FACETABLE_CONTENT_FIELD_PREFIX + fieldName); 496 497 AttributesImpl attr = new AttributesImpl(); 498 attr.addCDATAAttribute("name", fieldName); 499 attr.addCDATAAttribute("total", String.valueOf(values.values().stream().mapToInt(Integer::intValue).sum())); 500 XMLUtils.startElement(contentHandler, "facet", attr); 501 502 facets.get(fieldName).getLabel().toSAX(contentHandler, "label"); 503 504 Set<Entry<String, Integer>> entrySet = values.entrySet(); 505 for (Entry<String, Integer> entry : entrySet) 506 { 507 AttributesImpl valueAttrs = new AttributesImpl(); 508 valueAttrs.addCDATAAttribute("value", entry.getKey()); 509 valueAttrs.addCDATAAttribute("count", Integer.toString(entry.getValue())); 510 511 XMLUtils.startElement(contentHandler, "item", valueAttrs); 512 facets.get(fieldName).getFacetLabel(entry.getKey(), LocaleUtils.toLocale(lang)).toSAX(contentHandler); 513 XMLUtils.endElement(contentHandler, "item"); 514 } 515 516 XMLUtils.endElement(contentHandler, "facet"); 517 } 518 519 } 520 } 521 522 XMLUtils.endElement(contentHandler, "facets"); 523 } 524 525 /** 526 * SAX the form criteria values 527 * @param request The request 528 * @param start The start index 529 * @param offset The number of results 530 * @throws SAXException if an error occurs while SAXing 531 */ 532 protected abstract void saxFormValues (Request request, int start, int offset) throws SAXException; 533 534 /** 535 * Get the query from request parameters 536 * @param request The request 537 * @param siteNames The site names. 538 * @param language The language 539 * @return The query object. 540 * @throws IllegalArgumentException If the search field is invalid. 541 */ 542 protected abstract Query getQuery(Request request, Collection<String> siteNames, String language) throws IllegalArgumentException; 543 544 /** 545 * Get the filter queries from the request parameters. 546 * @param request The request. 547 * @param siteNames The site names. 548 * @param language The language. 549 * @return A collection of filter queries. 550 * @throws IllegalArgumentException If a search field is invalid. 551 */ 552 protected abstract Collection<Query> getFilterQueries(Request request, Collection<String> siteNames, String language) throws IllegalArgumentException; 553 554 /** 555 * Template methods to disable/enable the processing of the facets during the search. 556 * @return <code>true</code> to enable facets 557 */ 558 protected boolean useFacets() 559 { 560 return parameters.getParameterAsBoolean("facets", false); 561 } 562 563 /** 564 * Get the facets from request parameters 565 * @param request The request 566 * @return The facet fields 567 * @throws IllegalArgumentException If the search field is invalid. 568 */ 569 protected abstract Map<String, FacetField> getFacets(Request request) throws IllegalArgumentException; 570 571 /** 572 * Get the facet.queries 573 * @param request The request 574 * @return The facet.queries 575 * @throws IllegalArgumentException If the search field is invalid. 576 */ 577 protected abstract Set<QueryFacet> getQueryFacets(Request request); 578 579 /** 580 * Get the facet values 581 * @param request The request 582 * @param siteNames The site names 583 * @param language The language 584 * @return The facet values 585 */ 586 protected abstract Map<String, List<String>> getFacetValues(Request request, Collection<String> siteNames, String language); 587 588 /** 589 * Get the facet.query values 590 * @param request The request 591 * @return The facet.query values 592 */ 593 protected abstract Collection<String> getQueryFacetValues(Request request); 594 595 /** 596 * SAX the result hits 597 * @param results The search results. 598 * @param start The start index 599 * @param maxResults The number of results to generate. 600 * @throws SAXException If an error occurs while SAXing 601 * @throws IOException If there is a low-level IO error 602 */ 603 protected abstract void saxHits(SearchResults<AmetysObject> results, int start, int maxResults) throws SAXException, IOException; 604 605 /** 606 * SAX a hit of type page. 607 * @param score The score of the page 608 * @param maxScore The maximum score of the search results 609 * @param page The page 610 * @throws SAXException If an error occurs while SAXing 611 */ 612 protected void saxPageHit(float score, float maxScore, Page page) throws SAXException 613 { 614 int percent = Math.min(Math.round(score * 100f / maxScore), 100); 615 616 XMLUtils.startElement(contentHandler, "hit"); 617 XMLUtils.createElement(contentHandler, "score", Float.toString(score)); 618 XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent)); 619 XMLUtils.createElement(contentHandler, "title", page.getTitle()); 620 621 _saxPageContents(page); 622 623 XMLUtils.createElement(contentHandler, "type", "page"); 624 XMLUtils.createElement(contentHandler, "uri", page.getSitemap().getName() + "/" + page.getPathInSitemap()); 625 626 _saxLastModifiedDate(page); 627 _saxLastValidationDate(page); 628 629 String siteName = page.getSiteName(); 630 if (siteName != null) 631 { 632 Site site = _siteManager.getSite(siteName); 633 XMLUtils.createElement(contentHandler, "siteName", siteName); 634 XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle()); 635 String url = site.getUrl(); 636 if (url != null) 637 { 638 XMLUtils.createElement(contentHandler, "siteUrl", url); 639 } 640 } 641 642 saxAdditionalInfosOnPageHit(page); 643 644 XMLUtils.endElement(contentHandler, "hit"); 645 } 646 647 private void _saxPageContents(Page page) throws SAXException 648 { 649 if (page.getType() == PageType.CONTAINER) 650 { 651 for (Zone zone : page.getZones()) 652 { 653 AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems(); 654 for (ZoneItem zoneItem : zoneItems) 655 { 656 if (zoneItem.getType() == ZoneType.CONTENT) 657 { 658 // Content 659 Content content = zoneItem.getContent(); 660 saxContent(content.getId(), "index", LocaleUtils.toLocale(page.getSitemapName())); 661 } 662 } 663 } 664 } 665 } 666 667 private void _saxLastModifiedDate(Page page) throws SAXException 668 { 669 ZonedDateTime lastModified = null; 670 671 if (page.getType() == PageType.CONTAINER) 672 { 673 for (Zone zone : page.getZones()) 674 { 675 AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems(); 676 for (ZoneItem zoneItem : zoneItems) 677 { 678 switch (zoneItem.getType()) 679 { 680 case SERVICE: 681 // A service has no last modification date 682 break; 683 case CONTENT: 684 ZonedDateTime contentLastModified = zoneItem.getContent().getLastModified(); 685 686 if (contentLastModified != null && (lastModified == null || contentLastModified.isAfter(lastModified))) 687 { 688 // Keep the latest modification date 689 lastModified = contentLastModified; 690 } 691 break; 692 default: 693 break; 694 } 695 } 696 } 697 } 698 if (lastModified != null) 699 { 700 XMLUtils.createElement(contentHandler, "lastModified", DateUtils.zonedDateTimeToString(lastModified)); 701 } 702 } 703 704 private void _saxLastValidationDate(Page page) throws SAXException 705 { 706 ZonedDateTime lastValidated = null; 707 708 if (page.getType() == PageType.CONTAINER) 709 { 710 for (Zone zone : page.getZones()) 711 { 712 AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems(); 713 for (ZoneItem zoneItem : zoneItems) 714 { 715 switch (zoneItem.getType()) 716 { 717 case SERVICE: 718 // A service has no last validation date 719 break; 720 case CONTENT: 721 ZonedDateTime contentLastValidation = zoneItem.getContent().getLastValidationDate(); 722 723 if (contentLastValidation != null && (lastValidated == null || contentLastValidation.isAfter(lastValidated))) 724 { 725 // Keep the latest validation date 726 lastValidated = contentLastValidation; 727 } 728 break; 729 default: 730 break; 731 } 732 } 733 } 734 } 735 if (lastValidated != null) 736 { 737 XMLUtils.createElement(contentHandler, "lastValidation", DateUtils.zonedDateTimeToString(lastValidated)); 738 } 739 } 740 741 /** 742 * SAX additional information on page hit 743 * @param page the page 744 * @throws SAXException if something goes wrong when saxing the information 745 */ 746 protected void saxAdditionalInfosOnPageHit(Page page) throws SAXException 747 { 748 // Nothing to do here. 749 } 750 751 /** 752 * SAX a hit of type "resource". 753 * @param score The score of the page 754 * @param maxScore The maximum score of the search results 755 * @param resource The resource 756 * @throws SAXException If an error occurs while SAXing 757 */ 758 protected void saxResourceHit(float score, float maxScore, Resource resource) throws SAXException 759 { 760 int percent = Math.min(Math.round(score * 100f / maxScore), 100); 761 762 String filename = resource.getName(); 763 764 XMLUtils.startElement(contentHandler, "hit"); 765 XMLUtils.createElement(contentHandler, "score", Float.toString(score)); 766 XMLUtils.createElement(contentHandler, "percent", Integer.toString(percent)); 767 XMLUtils.createElement(contentHandler, "filename", filename); 768 XMLUtils.createElement(contentHandler, "title", StringUtils.substringBeforeLast(resource.getName(), ".")); 769 XMLUtils.createElement(contentHandler, "id", resource.getId()); 770 771 String dcDescription = resource.getDCDescription(); 772 String excerpt = _getResourceExcerpt(resource); 773 if (StringUtils.isNotBlank(dcDescription)) 774 { 775 XMLUtils.createElement(contentHandler, "excerpt", dcDescription); 776 } 777 else if (StringUtils.isNotBlank(excerpt)) 778 { 779 XMLUtils.createElement(contentHandler, "excerpt", excerpt + "..."); 780 } 781 782 XMLUtils.createElement(contentHandler, "type", "resource"); 783 784 Page page = _getResourcePage(resource); 785 if (page != null) 786 { 787 String pageUri = page.getSitemapName() + "/" + page.getPathInSitemap(); 788 String encodedPath = FilenameUtils.encodePath(resource.getResourcePath()); 789 790 String uri = URIUtils.encodeURI(pageUri + "/_attachment" + encodedPath, Collections.singletonMap("download", "true")); 791 XMLUtils.createElement(contentHandler, "uri", uri); 792 } 793 794 XMLUtils.createElement(contentHandler, "mime-types", resource.getMimeType()); 795 _saxSize(resource.getLength()); 796 _saxIcon(filename); 797 798 Date lastModified = resource.getLastModified(); 799 if (lastModified != null) 800 { 801 XMLUtils.createElement(contentHandler, "lastModified", DateUtils.dateToString(lastModified)); 802 } 803 if (page != null) 804 { 805 Site site = page.getSite(); 806 XMLUtils.createElement(contentHandler, "siteName", site.getName()); 807 XMLUtils.createElement(contentHandler, "siteTitle", site.getTitle()); 808 XMLUtils.createElement(contentHandler, "siteUrl", site.getUrl()); 809 } 810 811 XMLUtils.endElement(contentHandler, "hit"); 812 } 813 814 private String _getResourceExcerpt(Resource resource) 815 { 816 try (InputStream is = resource.getInputStream()) 817 { 818 TikaProvider tikaProvider = (TikaProvider) manager.lookup(TikaProvider.ROLE); 819 Tika tika = tikaProvider.getTika(); 820 String value = tika.parseToString(is); 821 if (StringUtils.isNotBlank(value)) 822 { 823 int summaryEndIndex = value.lastIndexOf(' ', 200); 824 if (summaryEndIndex == -1) 825 { 826 summaryEndIndex = value.length(); 827 } 828 return value.substring(0, summaryEndIndex) + (summaryEndIndex != value.length() ? "…" : ""); 829 } 830 } 831 catch (Exception e) 832 { 833 getLogger().error("Unable to index resource at " + resource.getPath(), e); 834 } 835 return null; 836 } 837 838 private Page _getResourcePage(Resource resource) 839 { 840 if (resource != null) 841 { 842 AmetysObject parent = resource.getParent(); 843 while (parent != null) 844 { 845 if (parent instanceof Page) 846 { 847 // We have gone up to the page 848 return (Page) parent; 849 } 850 parent = parent.getParent(); 851 } 852 } 853 854 return null; 855 } 856 857 /** 858 * SAX elements for pagination 859 * @param totalHits The total number of result 860 * @param start The start index of search 861 * @param offset The max number of results per page 862 * @throws SAXException SAXException If an error occurs while SAXing 863 */ 864 protected void saxPagination(long totalHits, int start, int offset) throws SAXException 865 { 866 int nbPages = (int) Math.ceil((double) totalHits / (double) offset); 867 868 AttributesImpl atts = new AttributesImpl(); 869 atts.addCDATAAttribute("total", String.valueOf(nbPages)); // Number of pages 870 atts.addCDATAAttribute("start", String.valueOf(start)); // Index of the first hit 871 atts.addCDATAAttribute("end", start + offset > totalHits ? String.valueOf(totalHits) : String.valueOf(start + offset)); // Index of the last hit 872 873 XMLUtils.startElement(contentHandler, "pagination", atts); 874 875 for (int i = 0; i < nbPages; i++) 876 { 877 AttributesImpl attr = new AttributesImpl(); 878 attr.addAttribute("", "index", "index", "CDATA", String.valueOf(i + 1)); 879 attr.addAttribute("", "start", "start", "CDATA", String.valueOf(i * offset)); 880 XMLUtils.createElement(contentHandler, "page", attr); 881 } 882 883 XMLUtils.endElement(contentHandler, "pagination"); 884 } 885 886 /** 887 * Get the page index 888 * @param request The request 889 * @return The page index 890 */ 891 protected int getPageIndex(Request request) 892 { 893 Enumeration paramNames = request.getParameterNames(); 894 while (paramNames.hasMoreElements()) 895 { 896 String param = (String) paramNames.nextElement(); 897 if (param.startsWith("page-")) 898 { 899 return Integer.parseInt(param.substring("page-".length())); 900 } 901 } 902 return 1; 903 } 904 905 private void _saxIcon(String filename) throws SAXException 906 { 907 int index = filename.lastIndexOf('.'); 908 String extension = filename.substring(index + 1); 909 910 XMLUtils.createElement(contentHandler, "icon", "plugins/explorer/icon-medium/" + extension + ".png"); 911 } 912 913 private void _saxSize(long size) throws SAXException 914 { 915 org.ametys.core.util.StringUtils.toReadableDataSize(size).toSAX(contentHandler, "size"); 916 } 917 918 /** 919 * Generate the service identifiers: service group ID, ZoneItem ID, ... 920 * @throws SAXException if an error occurs SAXing data. 921 * @throws IOException if an error occurs SAXing data. 922 * @throws ProcessingException if a processing error occurs. 923 */ 924 protected void saxServiceIdentifiers() throws SAXException, IOException, ProcessingException 925 { 926 Request request = ObjectModelHelper.getRequest(objectModel); 927 ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM); 928 String serviceGroupId = parameters.getParameter("service-group-id", ""); 929 930 // The service group ID. 931 if (StringUtils.isNotEmpty(serviceGroupId)) 932 { 933 XMLUtils.createElement(contentHandler, "group-id", serviceGroupId); 934 } 935 936 // Generate the ZoneItem ID if it exists. 937 if (zoneItem != null) 938 { 939 AttributesImpl atts = new AttributesImpl(); 940 atts.addCDATAAttribute("id", zoneItem.getId()); 941 XMLUtils.createElement(contentHandler, "zone-item", atts); 942 } 943 } 944 945 /** 946 * Generate any additional information. 947 * @throws SAXException if an error occurs SAXing data. 948 * @throws IOException if an error occurs SAXing data. 949 * @throws ProcessingException if a processing error occurs. 950 */ 951 protected void saxAdditionalInfos() throws SAXException, IOException, ProcessingException 952 { 953 // Nothing to do here. 954 } 955 956 /** 957 * Get the search mode. 958 * @return the search mode as a string. 959 */ 960 protected String getSearchMode() 961 { 962 return parameters.getParameter("search-mode", "criteria-and-results"); 963 } 964 965 /** 966 * Check if the input is valid. 967 * @return true if the input is valid, false otherwise. 968 */ 969 protected boolean isInputValid() 970 { 971 boolean valid = true; 972 973 Request request = ObjectModelHelper.getRequest(objectModel); 974 ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM); 975 String serviceGroupId = parameters.getParameter("service-group-id", ""); 976 String requestParamGroupId = StringUtils.defaultString(request.getParameter("submit-form")); 977 String requestParamZoneItemId = StringUtils.defaultString(request.getParameter("zone-item-id")); 978 979 // If the generator is not used as part of a service (zoneItem is null), consider the input valid. 980 if (zoneItem != null) 981 { 982 // If the generator is used as part of a service and both "group ID" 983 // and "zone item ID" are missing from the input, consider the input valid. 984 if (StringUtils.isNotEmpty(requestParamGroupId) || StringUtils.isNotEmpty(requestParamZoneItemId)) 985 { 986 if (StringUtils.isEmpty(serviceGroupId)) 987 { 988 // No specified group ID: check the provided ZoneItem ID. 989 valid = requestParamZoneItemId.equals(zoneItem.getId()); 990 } 991 else 992 { 993 // Check the group ID against the one sent by the form. 994 valid = requestParamGroupId.equals(serviceGroupId); 995 } 996 } 997 } 998 return valid; 999 } 1000 1001 /** 1002 * SAX the view of a content if exists 1003 * @param contentId the id of the content 1004 * @param viewName The name of view to sax 1005 * @param defaultLocale The locale to use to sax localized values such as multilingual content or multilingual string. Only use if initial content's language is not null. 1006 * @throws SAXException if an exception occurs while saxing 1007 */ 1008 protected void saxContent(String contentId, String viewName, Locale defaultLocale) throws SAXException 1009 { 1010 try 1011 { 1012 Content content = _resolver.resolveById(contentId); 1013 String[] contentTypes = content.getTypes(); 1014 1015 // content-name 1016 XMLUtils.createElement(contentHandler, "content-name", content.getName()); 1017 1018 // content-types 1019 XMLUtils.startElement(contentHandler, "content-types"); 1020 Arrays.asList(contentTypes).forEach(LambdaUtils.wrapConsumer(cType -> XMLUtils.createElement(contentHandler, "content-type", cType))); 1021 XMLUtils.endElement(contentHandler, "content-types"); 1022 1023 View view = _contentTypesHelper.getView(viewName, contentTypes, content.getMixinTypes()); 1024 if (view != null) 1025 { 1026 Set<String> richTextAttributesPaths = ViewHelper.getModelItemsFromView(view) 1027 .parallelStream() 1028 .filter(Objects::nonNull) 1029 .filter(item -> ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(item.getType().getId())) 1030 .map(ModelItem::getPath) 1031 .collect(Collectors.toSet()); 1032 1033 for (String richTextAttributePath : richTextAttributesPaths) 1034 { 1035 Object value = content.getValue(richTextAttributePath, true); 1036 if (value != null && value instanceof RichText) 1037 { 1038 saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) value); 1039 } 1040 else if (value instanceof RichText[]) 1041 { 1042 for (Object v : (RichText[]) value) 1043 { 1044 saxRichTextExcerpt(richTextAttributePath, contentId, (RichText) v); 1045 } 1046 } 1047 } 1048 1049 AttributesImpl attrs = new AttributesImpl(); 1050 attrs.addCDATAAttribute("id", content.getId()); 1051 attrs.addCDATAAttribute("name", content.getName()); 1052 if (content.getLanguage() != null) 1053 { 1054 attrs.addCDATAAttribute("language", content.getLanguage()); 1055 } 1056 XMLUtils.startElement(contentHandler, "content", attrs); 1057 1058 content.dataToSAX(contentHandler, view, DataContext.newInstance().withLocale(defaultLocale)); 1059 1060 XMLUtils.endElement(contentHandler, "content"); 1061 } 1062 } 1063 catch (AmetysRepositoryException e) 1064 { 1065 getLogger().error("Cannot sax information about the content " + contentId, e); 1066 } 1067 } 1068 1069 /** 1070 * SAX excerpt for rich text 1071 * @param attributePath The path of attribute 1072 * @param contentId The content id 1073 * @param richText The rich text 1074 */ 1075 protected void saxRichTextExcerpt(String attributePath, String contentId, RichText richText) 1076 { 1077 SAXParser saxParser = null; 1078 try (InputStream is = richText.getInputStream()) 1079 { 1080 RichTextHandler txtHandler = new RichTextHandler(200); 1081 saxParser = (SAXParser) manager.lookup(SAXParser.ROLE); 1082 saxParser.parse(new InputSource(is), txtHandler); 1083 String textValue = txtHandler.getValue(); 1084 if (textValue != null) 1085 { 1086 XMLUtils.createElement(contentHandler, "excerpt", textValue); 1087 } 1088 } 1089 catch (Exception e) 1090 { 1091 getLogger().error("Cannot convert a richtextvalue at path '" + attributePath + "' of content '" + contentId + "'", e); 1092 } 1093 finally 1094 { 1095 manager.release(saxParser); 1096 } 1097 } 1098 1099 /** 1100 * <code>true</code> to check rights during search 1101 * @return <code>true</code> to check rights during search 1102 */ 1103 protected boolean _checkRights() 1104 { 1105 return parameters.getParameterAsBoolean("check-rights", true); 1106 } 1107 1108 /** 1109 * Interface representing a facet field 1110 * 1111 */ 1112 protected interface FacetField 1113 { 1114 /** 1115 * Get the definition for this facet 1116 * @return the facet definition 1117 */ 1118 public FacetDefinition getFacetDefinition(); 1119 1120 /** 1121 * Get the label of the facet 1122 * @return the label 1123 */ 1124 public I18nizableText getLabel(); 1125 1126 /** 1127 * Get the label for a facet value 1128 * @param value the value 1129 * @param currentLocale the current locale 1130 * @return the label for this value 1131 */ 1132 public I18nizableText getFacetLabel(String value, Locale currentLocale); 1133 } 1134 1135 /** 1136 * Facet field for content types 1137 * 1138 */ 1139 protected class ContentTypeFacetField implements FacetField 1140 { 1141 private FacetDefinition _facetDefinition; 1142 1143 /** 1144 * Constructor 1145 * @param facetDefinition The facet definition 1146 */ 1147 public ContentTypeFacetField(FacetDefinition facetDefinition) 1148 { 1149 _facetDefinition = facetDefinition; 1150 } 1151 1152 @Override 1153 public FacetDefinition getFacetDefinition() 1154 { 1155 return _facetDefinition; 1156 } 1157 1158 @Override 1159 public I18nizableText getLabel() 1160 { 1161 return new I18nizableText("plugin.web", "PLUGINS_WEB_SERVICE_FRONT_SEARCH_CONTENT_TYPE_FACET_LABEL"); 1162 } 1163 1164 @Override 1165 public I18nizableText getFacetLabel(String value, Locale currentLocale) 1166 { 1167 ContentType cType = _cTypeExtPt.getExtension(value); 1168 if (cType != null) 1169 { 1170 return cType.getLabel(); 1171 } 1172 1173 return new I18nizableText(value); 1174 } 1175 } 1176 1177 /** 1178 * Facet field for an attribute 1179 * 1180 */ 1181 protected class AttributeFacetField implements FacetField 1182 { 1183 private FacetDefinition _facetDefinition; 1184 private ModelItem _modelItem; 1185 private Logger _logger; 1186 1187 /** 1188 * Constructor 1189 * @param facetDefinition The facet definition 1190 * @param modelItem The model item 1191 * @param logger The logger 1192 */ 1193 public AttributeFacetField(FacetDefinition facetDefinition, ModelItem modelItem, Logger logger) 1194 { 1195 _facetDefinition = facetDefinition; 1196 _modelItem = modelItem; 1197 _logger = logger; 1198 } 1199 1200 @Override 1201 public FacetDefinition getFacetDefinition() 1202 { 1203 return _facetDefinition; 1204 } 1205 1206 @Override 1207 public I18nizableText getLabel() 1208 { 1209 return _modelItem.getLabel(); 1210 } 1211 1212 @Override 1213 public I18nizableText getFacetLabel(String value, Locale currentLocale) 1214 { 1215 try 1216 { 1217 if (_modelItem != null && _modelItem instanceof ElementDefinition) 1218 { 1219 ElementDefinition elementDefinition = (ElementDefinition) _modelItem; 1220 if (elementDefinition.getEnumerator() != null) 1221 { 1222 return elementDefinition.getEnumerator().getEntry(value); 1223 } 1224 else if (elementDefinition.getType().getId().equals(ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID)) 1225 { 1226 Content content = _resolver.resolveById(value); 1227 return new I18nizableText(content.getTitle(currentLocale)); 1228 } 1229 } 1230 } 1231 catch (Exception e) 1232 { 1233 _logger.error("Failed to get label of facet value '" + value + "'. Raw value itself will be used.", e); 1234 } 1235 1236 return new I18nizableText(value); 1237 } 1238 } 1239}