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