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.Objects; 024import java.util.Optional; 025import java.util.stream.Collectors; 026 027import org.apache.avalon.framework.parameters.Parameters; 028import org.apache.avalon.framework.service.ServiceException; 029import org.apache.avalon.framework.service.ServiceManager; 030import org.apache.cocoon.ProcessingException; 031import org.apache.cocoon.acting.ServiceableAction; 032import org.apache.cocoon.environment.ObjectModelHelper; 033import org.apache.cocoon.environment.Redirector; 034import org.apache.cocoon.environment.Request; 035import org.apache.cocoon.environment.SourceResolver; 036import org.apache.commons.lang.StringUtils; 037import org.apache.solr.client.solrj.util.ClientUtils; 038 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.ContentTypeQuery; 045import org.ametys.cms.search.query.DocumentTypeQuery; 046import org.ametys.cms.search.query.OrQuery; 047import org.ametys.cms.search.query.Query; 048import org.ametys.cms.search.query.Query.Operator; 049import org.ametys.cms.search.query.QueryHelper; 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.core.cocoon.JSonReader; 054import org.ametys.core.util.URIUtils; 055import org.ametys.odf.ProgramItem; 056import org.ametys.odf.course.Course; 057import org.ametys.odf.enumeration.OdfReferenceTableHelper; 058import org.ametys.odf.program.AbstractProgram; 059import org.ametys.odf.program.Program; 060import org.ametys.odf.program.ProgramFactory; 061import org.ametys.odf.program.SubProgram; 062import org.ametys.plugins.odfweb.repository.OdfPageResolver; 063import org.ametys.plugins.repository.AmetysObjectIterable; 064import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 065import org.ametys.web.URIPrefixHandler; 066import org.ametys.web.frontoffice.search.instance.SearchServiceInstance; 067import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager; 068import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode; 069import org.ametys.web.frontoffice.search.instance.model.RightCheckingMode; 070import org.ametys.web.renderingcontext.RenderingContext; 071import org.ametys.web.renderingcontext.RenderingContextHandler; 072import org.ametys.web.repository.page.Page; 073 074/** 075 * Get the proposed program's pages and skills for auto-completion while beginning a search 076 */ 077public class AutocompletionSearchAction extends ServiceableAction 078{ 079 /** The default max number of results */ 080 protected static final int NB_MAX_RESULTS = 10; 081 082 /** The search factory */ 083 protected SearcherFactory _searcherFactory; 084 085 /** Component for search service */ 086 protected SearchServiceInstanceManager _searchServiceInstanceManager; 087 088 private OdfPageResolver _odfPageResolver; 089 090 private URIPrefixHandler _prefixHandler; 091 092 private RenderingContextHandler _renderingContextHandler; 093 094 @Override 095 public void service(ServiceManager serviceManager) throws ServiceException 096 { 097 super.service(serviceManager); 098 _searcherFactory = (SearcherFactory) serviceManager.lookup(SearcherFactory.ROLE); 099 _searchServiceInstanceManager = (SearchServiceInstanceManager) serviceManager.lookup(SearchServiceInstanceManager.ROLE); 100 _odfPageResolver = (OdfPageResolver) serviceManager.lookup(OdfPageResolver.ROLE); 101 _prefixHandler = (URIPrefixHandler) serviceManager.lookup(URIPrefixHandler.ROLE); 102 _renderingContextHandler = (RenderingContextHandler) manager.lookup(RenderingContextHandler.ROLE); 103 } 104 105 public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception 106 { 107 Request request = ObjectModelHelper.getRequest(objectModel); 108 109 String siteName = request.getParameter("siteName"); 110 if (StringUtils.isEmpty(siteName)) 111 { 112 throw new IllegalArgumentException("A site must be specified for auto completion"); 113 } 114 115 String lang = request.getParameter("lang"); 116 if (StringUtils.isEmpty(lang)) 117 { 118 throw new IllegalArgumentException("A language must be specified for auto completion"); 119 } 120 121 String zoneItemId = request.getParameter("zoneItemId"); 122 List<String> catalogNames = getCatalogNames(zoneItemId); 123 RightCheckingMode rightCheckingMode = getRightCheckingMode(zoneItemId); 124 125 int limit = request.getParameter("limit") != null ? Integer.valueOf(request.getParameter("limit")) : NB_MAX_RESULTS; 126 127 // Retrieve current workspace 128 String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 129 130 String contentType = parameters.getParameter("contentType", ProgramFactory.PROGRAM_CONTENT_TYPE); 131 132 try 133 { 134 String query = request.getParameter("q"); 135 String escapedQuery = _escapeQuery(query); 136 137 Map<String, Object> results = new HashMap<>(); 138 139 // Compute common ODF root page if search is configured for one catalog 140 Page odfRootPage = catalogNames.size() == 1 ? _odfPageResolver.getOdfRootPage(siteName, lang, catalogNames.get(0)) : null; 141 142 // Search on content's title with the given content type 143 AmetysObjectIterable<Content> contents = getContents(contentType, siteName, lang, catalogNames, escapedQuery, limit, rightCheckingMode); 144 List<Map<String, Object>> pages = contents.stream() 145 .map(c -> getPageHit(c, siteName, odfRootPage)) 146 .filter(Objects::nonNull) 147 .collect(Collectors.toList()); 148 results.put("pages", pages); 149 150 // Search on skill's title 151 AmetysObjectIterable<Content> skillResults = getSkills(escapedQuery, lang, limit, rightCheckingMode); 152 List<Map<String, Object>> skills = skillResults.stream() 153 .map(this::getContentHit) 154 .collect(Collectors.toList()); 155 156 results.put("skills", skills); 157 158 request.setAttribute(JSonReader.OBJECT_TO_READ, results); 159 return EMPTY_MAP; 160 } 161 catch (Exception e) 162 { 163 getLogger().error("Error getting auto-complete list.", e); 164 throw new ProcessingException("Error getting auto-complete list.", e); 165 } 166 finally 167 { 168 // Restore context 169 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp); 170 } 171 } 172 173 /** 174 * Get the configured catalog in search criteria 175 * @param zoneItemId The id of zone item 176 * @return the catalog's name 177 */ 178 protected List<String> getCatalogNames(String zoneItemId) 179 { 180 if (StringUtils.isNotEmpty(zoneItemId)) 181 { 182 SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItemId); 183 184 List<String> catalogValues = serviceInstance.getCriterionTree() 185 .map(AbstractTreeNode::getFlatLeaves) 186 .orElseGet(Collections::emptyList) 187 .stream() 188 .map(TreeLeaf::getValue) 189 .filter(c -> c.getMode() == FOSearchCriterionMode.STATIC) 190 .filter(c -> StringUtils.endsWith(c.getCriterionDefinition().getId(), "$indexingField$org.ametys.plugins.odf.Content.programItem$catalog")) 191 .map(c -> c.getStaticValue()) 192 .filter(Optional::isPresent) 193 .map(Optional::get) 194 .map(String.class::cast) 195 .collect(Collectors.toList()); 196 197 return catalogValues; 198 } 199 else 200 { 201 return List.of(); 202 } 203 204 } 205 206 /** 207 * Get the mode for checking rights 208 * @param zoneItemId the id of zone item 209 * @return the mode for checking rights 210 */ 211 protected RightCheckingMode getRightCheckingMode(String zoneItemId) 212 { 213 return Optional.ofNullable(zoneItemId) 214 .filter(StringUtils::isNotEmpty) 215 .map(s -> _searchServiceInstanceManager.get(zoneItemId)) 216 .map(SearchServiceInstance::getRightCheckingMode) 217 .orElse(RightCheckingMode.EXACT); 218 } 219 220 /** 221 * Get the content pages matching the query 222 * @param cType The content type of pages to search 223 * @param siteName the site name 224 * @param lang the language 225 * @param catalogNames The name of catalog to take into account. Can be empty 226 * @param escapedQuery the query 227 * @param limit the max number of results 228 * @param rightCheckingMode the mode for checking rights 229 * @return the matching pages 230 * @throws Exception if an error occurred during search 231 */ 232 protected AmetysObjectIterable<Content> getContents(String cType, String siteName, String lang, List<String> catalogNames, String escapedQuery, int limit, RightCheckingMode rightCheckingMode) throws Exception 233 { 234 List<Query> queries = new ArrayList<>(); 235 queries.add(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true)); 236 237 if (!catalogNames.isEmpty()) 238 { 239 List<Query> catalogQueries = new ArrayList<>(); 240 for (String catalogName : catalogNames) 241 { 242 catalogQueries.add(new StringQuery(ProgramItem.CATALOG, Operator.EQ, catalogName, lang)); 243 } 244 queries.add(new OrQuery(catalogQueries)); 245 } 246 247 Searcher searcher = _searcherFactory.create() 248 .withQuery(new AndQuery(queries)) 249 .addFilterQuery(new DocumentTypeQuery(SolrFieldNames.TYPE_CONTENT)) 250 .addFilterQueryString(SolrFieldNames.CONTENT_TYPES + ":" + cType) 251 .withLimits(0, limit); 252 253 _setRightCheckingMode(searcher, rightCheckingMode); 254 255 return searcher.search(); 256 } 257 258 /** 259 * Get the skills contents matching the query 260 * @param escapedQuery the query 261 * @param lang the language 262 * @param limit the max number of results 263 * @param rightCheckingMode the mode for checking rights 264 * @return the matching contents 265 * @throws Exception if an error occurred during search 266 */ 267 protected AmetysObjectIterable<Content> getSkills(String escapedQuery, String lang, int limit, RightCheckingMode rightCheckingMode) throws Exception 268 { 269 Searcher searcher = _searcherFactory.create() 270 .withQuery(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true)) 271 .addFilterQuery(new DocumentTypeQuery(SolrFieldNames.TYPE_CONTENT)) 272 .addFilterQuery(new ContentTypeQuery(OdfReferenceTableHelper.SKILL)) 273 .withLimits(0, limit); 274 275 _setRightCheckingMode(searcher, rightCheckingMode); 276 277 return searcher.search(); 278 } 279 280 /** 281 * Set whether to check rights when searching, 282 * @param searcher the searcher 283 * @param rightCheckingMode the the mode for checking rights 284 */ 285 protected void _setRightCheckingMode(Searcher searcher, RightCheckingMode rightCheckingMode) 286 { 287 switch (rightCheckingMode) 288 { 289 case EXACT: 290 case FAST: 291 // FAST will be force to EXACT because of user inputs 292 searcher.setCheckRights(true); 293 break; 294 case NONE: 295 searcher.setCheckRights(false); 296 break; 297 default: 298 throw new IllegalStateException("Unhandled right checking mode: " + rightCheckingMode); 299 } 300 } 301 302 private String _escapeQuery(String text) 303 { 304 String trimText = StringUtils.strip(text.trim(), "*"); 305 return "*" + QueryHelper.escapeQueryCharsExceptStarsAndWhitespaces(trimText) + "*"; 306 } 307 308 /** 309 * Get the JSON representation of a page hit 310 * @param content the content 311 * @param siteName the current site name 312 * @param odfRootPage the ODF root page. Can be null if search is configured for multiple catalogs 313 * @return the page as json 314 */ 315 protected Map<String, Object> getPageHit(Content content, String siteName, Page odfRootPage) 316 { 317 Page page = null; 318 if (content instanceof Program) 319 { 320 page = odfRootPage != null ? _odfPageResolver.getProgramPage(odfRootPage, (Program) content) : _odfPageResolver.getProgramPage((Program) content, siteName); 321 } 322 else if (content instanceof SubProgram) 323 { 324 page = odfRootPage != null ? _odfPageResolver.getSubProgramPage(odfRootPage, (SubProgram) content, null) : _odfPageResolver.getSubProgramPage((SubProgram) content, null, siteName); 325 } 326 else if (content instanceof Course) 327 { 328 page = odfRootPage != null ? _odfPageResolver.getCoursePage(odfRootPage, (Course) content, (AbstractProgram) null) : _odfPageResolver.getCoursePage((Course) content, (AbstractProgram) null, siteName); 329 } 330 331 if (page != null) 332 { 333 Map<String, Object> result = new HashMap<>(); 334 result.put("title", page.getTitle()); 335 336 RenderingContext context = _renderingContextHandler.getRenderingContext(); 337 if (!(context == RenderingContext.BACK)) 338 { 339 StringBuilder uri = new StringBuilder(); 340 uri.append(_prefixHandler.getUriPrefix(siteName)); 341 uri.append("/"); 342 uri.append(page.getSitemapName() + "/" + page.getPathInSitemap() + ".html"); 343 344 result.put("url", URIUtils.encodePath(uri.toString())); 345 } 346 else // back 347 { 348 result.put("url", "javascript:(function(){parent.Ametys.tool.ToolsManager.openTool('uitool-page', {id:'" + page.getId() + "'});})()"); 349 } 350 351 return result; 352 } 353 354 return null; 355 } 356 357 /** 358 * Get the JSON representation of a content hit 359 * @param content the content 360 * @return the content as json 361 */ 362 protected Map<String, Object> getContentHit(Content content) 363 { 364 Map<String, Object> result = new HashMap<>(); 365 366 result.put("title", content.getTitle()); 367 result.put("id", content.getId()); 368 369 return result; 370 } 371 372}