001/* 002 * Copyright 2019 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.odfweb.service.search; 017 018import java.util.ArrayList; 019import java.util.Collections; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Optional; 024import java.util.stream.Collectors; 025 026import org.apache.avalon.framework.parameters.Parameters; 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.cocoon.ProcessingException; 030import org.apache.cocoon.acting.ServiceableAction; 031import org.apache.cocoon.environment.ObjectModelHelper; 032import org.apache.cocoon.environment.Redirector; 033import org.apache.cocoon.environment.Request; 034import org.apache.cocoon.environment.SourceResolver; 035import org.apache.commons.lang.StringUtils; 036import org.apache.solr.client.solrj.util.ClientUtils; 037 038import org.ametys.cms.content.ContentHelper; 039import org.ametys.cms.content.indexing.solr.SolrFieldNames; 040import org.ametys.cms.repository.Content; 041import org.ametys.cms.search.advanced.AbstractTreeNode; 042import org.ametys.cms.search.advanced.TreeLeaf; 043import org.ametys.cms.search.query.AndQuery; 044import org.ametys.cms.search.query.ContentQuery; 045import org.ametys.cms.search.query.ContentTypeQuery; 046import org.ametys.cms.search.query.DocumentTypeQuery; 047import org.ametys.cms.search.query.OrQuery; 048import org.ametys.cms.search.query.Query; 049import org.ametys.cms.search.query.Query.Operator; 050import org.ametys.cms.search.query.StringQuery; 051import org.ametys.cms.search.solr.SearcherFactory; 052import org.ametys.cms.search.solr.SearcherFactory.Searcher; 053import org.ametys.cms.transformation.xslt.ResolveURIComponent; 054import org.ametys.core.cocoon.JSonReader; 055import org.ametys.odf.ProgramItem; 056import org.ametys.odf.enumeration.OdfReferenceTableHelper; 057import org.ametys.odf.program.ProgramFactory; 058import org.ametys.plugins.repository.AmetysObjectIterable; 059import org.ametys.plugins.repository.AmetysObjectResolver; 060import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 061import org.ametys.web.frontoffice.search.instance.SearchServiceInstance; 062import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager; 063import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode; 064import org.ametys.web.indexing.solr.SolrWebFieldNames; 065import org.ametys.web.repository.page.Page; 066import org.ametys.web.search.query.PageContentQuery; 067import org.ametys.web.search.query.SiteQuery; 068import org.ametys.web.search.query.SitemapQuery; 069 070/** 071 * Get the proposed program's pages and skills for auto-completion while beginning a search 072 */ 073public class AutocompletionSearchAction extends ServiceableAction 074{ 075 /** The default max number of results */ 076 protected static final int NB_MAX_RESULTS = 10; 077 078 /** The search factory */ 079 protected SearcherFactory _searcherFactory; 080 081 /** Component for search service */ 082 protected SearchServiceInstanceManager _searchServiceInstanceManager; 083 084 private ContentHelper _contentHelper; 085 086 private AmetysObjectResolver _resolver; 087 088 089 @Override 090 public void service(ServiceManager serviceManager) throws ServiceException 091 { 092 super.service(serviceManager); 093 _searcherFactory = (SearcherFactory) serviceManager.lookup(SearcherFactory.ROLE); 094 _searchServiceInstanceManager = (SearchServiceInstanceManager) serviceManager.lookup(SearchServiceInstanceManager.ROLE); 095 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 096 _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE); 097 } 098 099 public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception 100 { 101 Request request = ObjectModelHelper.getRequest(objectModel); 102 103 String siteName = request.getParameter("siteName"); 104 if (StringUtils.isEmpty(siteName)) 105 { 106 throw new IllegalArgumentException("A site must be specified for auto completion"); 107 } 108 109 String lang = request.getParameter("lang"); 110 if (StringUtils.isEmpty(lang)) 111 { 112 throw new IllegalArgumentException("A language must be specified for auto completion"); 113 } 114 115 String zoneItemId = request.getParameter("zoneItemId"); 116 List<String> catalogNames = getCatalogNames(zoneItemId); 117 118 int limit = request.getParameter("limit") != null ? Integer.valueOf(request.getParameter("limit")) : NB_MAX_RESULTS; 119 120 // Retrieve current workspace 121 String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 122 123 String contentType = parameters.getParameter("contentType", ProgramFactory.PROGRAM_CONTENT_TYPE); 124 125 try 126 { 127 String query = request.getParameter("q"); 128 String escapedQuery = _escapeQuery(query); 129 130 Map<String, Object> results = new HashMap<>(); 131 132 // Search on page's title with the given content type 133 AmetysObjectIterable<Page> pageResults = getContentPages(contentType, siteName, lang, catalogNames, escapedQuery, limit); 134 List<Map<String, Object>> pages = pageResults.stream() 135 .map(this::getPageHit) 136 .collect(Collectors.toList()); 137 results.put("pages", pages); 138 139 // Search on skill's title 140 AmetysObjectIterable<Content> skillResults = getSkills(escapedQuery, lang, limit); 141 List<Map<String, Object>> skills = skillResults.stream() 142 .map(this::getContentHit) 143 .collect(Collectors.toList()); 144 145 results.put("skills", skills); 146 147 request.setAttribute(JSonReader.OBJECT_TO_READ, results); 148 return EMPTY_MAP; 149 } 150 catch (Exception e) 151 { 152 getLogger().error("Error getting auto-complete list.", e); 153 throw new ProcessingException("Error getting auto-complete list.", e); 154 } 155 finally 156 { 157 // Restore context 158 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp); 159 } 160 } 161 162 /** 163 * Get the configured catalog in search criteria 164 * @param zoneItemId The id of zone item 165 * @return the catalog's name 166 */ 167 protected List<String> getCatalogNames(String zoneItemId) 168 { 169 if (StringUtils.isNotEmpty(zoneItemId)) 170 { 171 SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItemId); 172 173 List<String> catalogValues = serviceInstance.getCriterionTree() 174 .map(AbstractTreeNode::getFlatLeaves) 175 .orElseGet(Collections::emptyList) 176 .stream() 177 .map(TreeLeaf::getValue) 178 .filter(c -> c.getMode() == FOSearchCriterionMode.STATIC) 179 .filter(c -> StringUtils.endsWith(c.getCriterionDefinition().getId(), "$indexingField$org.ametys.plugins.odf.Content.programItem$catalog")) 180 .map(c -> c.getStaticValue()) 181 .filter(Optional::isPresent) 182 .map(Optional::get) 183 .map(String.class::cast) 184 .collect(Collectors.toList()); 185 186 return catalogValues; 187 } 188 else 189 { 190 return List.of(); 191 } 192 193 } 194 195 /** 196 * Get the content pages matching the query 197 * @param cType The content type of pages to search 198 * @param siteName the site name 199 * @param lang the language 200 * @param catalogNames The name of catalog to take into account. Can be empty 201 * @param escapedQuery the query 202 * @param limit the max number of results 203 * @return the matching pages 204 * @throws Exception if an error occurred during search 205 */ 206 protected AmetysObjectIterable<Page> getContentPages(String cType, String siteName, String lang, List<String> catalogNames, String escapedQuery, int limit) throws Exception 207 { 208 List<Query> queries = new ArrayList<>(); 209 queries.add(new StringQuery(SolrWebFieldNames.PAGE_TITLE, Operator.SEARCH, escapedQuery, lang, true)); 210 211 if (!catalogNames.isEmpty()) 212 { 213 List<Query> joinQueries = new ArrayList<>(); 214 for (String catalogName : catalogNames) 215 { 216 joinQueries.add(new PageContentQuery(new ContentQuery(ProgramItem.CATALOG, Operator.EQ, catalogName, _resolver, _contentHelper))); 217 } 218 queries.add(new OrQuery(joinQueries)); 219 } 220 221 Searcher searcher = _searcherFactory.create() 222 .withQuery(new AndQuery(queries)) 223 .addFilterQuery(new DocumentTypeQuery(SolrWebFieldNames.TYPE_PAGE)) 224 .addFilterQuery(new SiteQuery(siteName)) 225 .addFilterQuery(new SitemapQuery(lang)) 226 .addFilterQueryString(SolrWebFieldNames.PAGE_CONTENT_TYPES + ":" + cType) 227 .withLimits(0, limit) 228 .setCheckRights(true); 229 230 return searcher.search(); 231 } 232 233 /** 234 * Get the skills contents matching the query 235 * @param escapedQuery the query 236 * @param lang the language 237 * @param limit the max number of results 238 * @return the matching contents 239 * @throws Exception if an error occurred during search 240 */ 241 protected AmetysObjectIterable<Content> getSkills(String escapedQuery, String lang, int limit) throws Exception 242 { 243 Searcher searcher = _searcherFactory.create() 244 .withQuery(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true)) 245 .addFilterQuery(new DocumentTypeQuery(SolrFieldNames.TYPE_CONTENT)) 246 .addFilterQuery(new ContentTypeQuery(OdfReferenceTableHelper.SKILL)) 247 .withLimits(0, limit) 248 .setCheckRights(true); 249 250 return searcher.search(); 251 } 252 253 private String _escapeQuery(String text) 254 { 255 String trimText = StringUtils.strip(text.trim(), "*"); 256 return "*" + ClientUtils.escapeQueryChars(trimText) + "*"; 257 } 258 259 /** 260 * Get the JSON representation of a page hit 261 * @param page the page 262 * @return the page as json 263 */ 264 protected Map<String, Object> getPageHit(Page page) 265 { 266 Map<String, Object> result = new HashMap<>(); 267 268 result.put("title", page.getTitle()); 269 result.put("id", page.getId()); 270 result.put("url", ResolveURIComponent.resolve("page", page.getId())); 271 272 return result; 273 } 274 275 /** 276 * Get the JSON representation of a content hit 277 * @param content the content 278 * @return the content as json 279 */ 280 protected Map<String, Object> getContentHit(Content content) 281 { 282 Map<String, Object> result = new HashMap<>(); 283 284 result.put("title", content.getTitle()); 285 result.put("id", content.getId()); 286 287 return result; 288 } 289 290}