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.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.commons.lang3.StringUtils; 030import org.apache.commons.lang3.Strings; 031 032import org.ametys.cms.content.indexing.solr.SolrFieldNames; 033import org.ametys.cms.contenttype.ContentTypesHelper; 034import org.ametys.cms.data.ContentValue; 035import org.ametys.cms.repository.Content; 036import org.ametys.cms.repository.ModifiableContent; 037import org.ametys.cms.search.advanced.AbstractTreeNode; 038import org.ametys.cms.search.advanced.TreeLeaf; 039import org.ametys.cms.search.query.AndQuery; 040import org.ametys.cms.search.query.ContentTypeQuery; 041import org.ametys.cms.search.query.DocumentTypeQuery; 042import org.ametys.cms.search.query.JoinQuery; 043import org.ametys.cms.search.query.OrQuery; 044import org.ametys.cms.search.query.Query; 045import org.ametys.cms.search.query.Query.Operator; 046import org.ametys.cms.search.query.join.JoinKey; 047import org.ametys.cms.search.query.StringQuery; 048import org.ametys.cms.search.solr.SearcherFactory.Searcher; 049import org.ametys.core.util.URIUtils; 050import org.ametys.odf.course.Course; 051import org.ametys.odf.program.AbstractProgram; 052import org.ametys.odf.program.Program; 053import org.ametys.odf.program.ProgramFactory; 054import org.ametys.odf.program.SubProgram; 055import org.ametys.odf.skill.ODFSkillsHelper; 056import org.ametys.odf.skill.workflow.SkillEditionFunction; 057import org.ametys.plugins.odfweb.repository.OdfPageResolver; 058import org.ametys.plugins.repository.AmetysObject; 059import org.ametys.plugins.repository.AmetysObjectIterable; 060import org.ametys.web.frontoffice.search.fast.AbstractAutocompletionSearchServiceAction; 061import org.ametys.web.frontoffice.search.instance.SearchServiceInstance; 062import org.ametys.web.frontoffice.search.instance.model.RightCheckingMode; 063import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion; 064import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode; 065import org.ametys.web.renderingcontext.RenderingContext; 066import org.ametys.web.repository.page.Page; 067import org.ametys.web.repository.site.Site; 068 069/** 070 * Get the proposed program's pages and skills for auto-completion while beginning a search 071 */ 072public class ProgramItemAutocompletionSearchServiceAction extends AbstractAutocompletionSearchServiceAction 073{ 074 /** The ODF page resolver */ 075 protected OdfPageResolver _odfPageResolver; 076 /** The content types helper */ 077 protected ContentTypesHelper _contentTypesHelper; 078 079 @Override 080 public void service(ServiceManager serviceManager) throws ServiceException 081 { 082 super.service(serviceManager); 083 _odfPageResolver = (OdfPageResolver) serviceManager.lookup(OdfPageResolver.ROLE); 084 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 085 } 086 087 @Override 088 protected Map<String, Object> _searchAmetysObject(Site site, String zoneItemId, String lang, String escapedQuery, int limit, RightCheckingMode rightCheckingMode) throws Exception 089 { 090 Map<String, Object> results = super._searchAmetysObject(site, zoneItemId, lang, escapedQuery, limit, rightCheckingMode); 091 092 if (ODFSkillsHelper.isSkillsEnabled()) 093 { 094 List<String> catalogNames = _getCatalogNames(zoneItemId); 095 Query catalogQuery = catalogNames.stream() 096 .map(c -> new StringQuery("catalog", c)) 097 .collect(OrQuery.collector()); 098 099 // Search on skill's title 100 AmetysObjectIterable<Content> macroSkillResults = _getMacroSkills(escapedQuery, catalogQuery, lang, limit, rightCheckingMode); 101 102 Map<String, Map<String, Object>> skills = macroSkillResults.stream() 103 // Retrieve the macro skills json by its id 104 .collect(Collectors.toMap( 105 macroSkill -> macroSkill.getId(), 106 this::_getContentHit, 107 (a, b) -> a 108 )); 109 110 AmetysObjectIterable<Content> microSkillResults = _getMicroSkills(escapedQuery, catalogQuery, lang, limit, rightCheckingMode); 111 112 _addMicroSkillToHitContents(skills, microSkillResults); 113 114 results.put("skills", skills.values()); 115 } 116 117 return results; 118 } 119 120 @SuppressWarnings("unchecked") 121 private void _addMicroSkillToHitContents(Map<String, Map<String, Object>> skills, AmetysObjectIterable<Content> microSkillResults) 122 { 123 for (Content microSkill : microSkillResults) 124 { 125 ContentValue parentMacroSkill = microSkill.getValue("parentMacroSkill"); 126 // If skill is not an orphan, we add it to its parent macro skill 127 if (parentMacroSkill != null) 128 { 129 String parentId = parentMacroSkill.getContentId(); 130 Optional<ModifiableContent> parentIfExist = parentMacroSkill.getContentIfExists(); 131 // Check that the parent macro skill is valid (if it is already in the results, it has already been checked) 132 if (parentIfExist.isPresent() && (skills.containsKey(parentId) || _isMacroValid(parentIfExist.get()))) 133 { 134 Map<String, Object> parentContentHitMap = skills.computeIfAbsent(parentId, m -> _getContentHit(parentIfExist.get())); 135 List<Map<String, Object>> matchingMicroSkills = (List<Map<String, Object>>) parentContentHitMap.computeIfAbsent("matchingMicroSkills", micros -> new ArrayList<>()); 136 137 matchingMicroSkills.add(_getContentHit(microSkill)); 138 } 139 } 140 } 141 } 142 143 /** 144 * Check if a macro skill is valid (not an orphan or transversal) 145 * @param macroSkill The macro skill to check 146 * @return <code>true</code> if the macro skill is valid, <code>false</code> otherwise 147 */ 148 private boolean _isMacroValid(Content macroSkill) 149 { 150 if (macroSkill != null) 151 { 152 // If it is a transversal macro, it cannot be orphan, so it is always valid 153 if (_contentTypesHelper.isInstanceOf(macroSkill, SkillEditionFunction.TRANSVERSAL_MACRO_SKILL_TYPE)) 154 { 155 return true; 156 } 157 158 // If it is a disciplinary macro skill, check that it is linked to an existing program 159 ContentValue parentProgram = macroSkill.getValue("parentProgram"); 160 if (parentProgram != null && parentProgram.getContentIfExists().isPresent()) 161 { 162 return true; 163 } 164 } 165 166 return false; 167 } 168 169 @Override 170 protected void setHitsInResults(Map<String, Object> results, AmetysObjectIterable<AmetysObject> searchHits, Site site, String zoneItemId, String lang) 171 { 172 List<String> catalogNames = _getCatalogNames(zoneItemId); 173 174 // Compute common ODF root page if search is configured for one catalog 175 Page odfRootPage = catalogNames.size() == 1 ? _odfPageResolver.getOdfRootPage(site.getName(), lang, catalogNames.get(0)) : null; 176 177 List<Map<String, Object>> pages = searchHits.stream() 178 .filter(Content.class::isInstance) 179 .map(Content.class::cast) 180 .map(c -> _getPageHit(c, site.getName(), odfRootPage)) 181 .filter(Objects::nonNull) 182 .collect(Collectors.toList()); 183 results.put("pages", pages); 184 } 185 186 /** 187 * Get the configured catalog in criterion definitions 188 * @param zoneItemId The id of zone item 189 * @return the catalog's name 190 */ 191 protected List<String> _getCatalogNames(String zoneItemId) 192 { 193 if (StringUtils.isNotEmpty(zoneItemId)) 194 { 195 SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItemId); 196 197 List<String> catalogValues = serviceInstance.getCriterionTree() 198 .map(AbstractTreeNode::getFlatLeaves) 199 .orElseGet(Collections::emptyList) 200 .stream() 201 .map(TreeLeaf::getValue) 202 .filter(c -> c.getMode() == SearchServiceCriterionMode.STATIC) 203 .filter(c -> Strings.CS.endsWith(c.getCriterionDefinition().getName(), "$org.ametys.plugins.odf.Content.programItem$catalog")) 204 .map(SearchServiceCriterion::getStaticValue) 205 .flatMap(Optional::stream) 206 .map(List.class::cast) 207 .flatMap(List::stream) 208 .map(String.class::cast) 209 .toList(); 210 211 return catalogValues; 212 } 213 else 214 { 215 return List.of(); 216 } 217 218 } 219 220 /** 221 * Get the non orphan macro skills matching the query 222 * @param escapedQuery the query 223 * @param catalogQuery The catalog query 224 * @param lang the language 225 * @param limit the max number of results 226 * @param rightCheckingMode the mode for checking rights 227 * @return the matching contents 228 * @throws Exception if an error occurred during search 229 */ 230 protected AmetysObjectIterable<Content> _getMacroSkills(String escapedQuery, Query catalogQuery, String lang, int limit, RightCheckingMode rightCheckingMode) throws Exception 231 { 232 Query programQuery = new AndQuery(catalogQuery, new ContentTypeQuery(ProgramFactory.PROGRAM_CONTENT_TYPE)); 233 234 // Search for macro skills that are not orphans (either transversal or linked to a program) 235 ContentTypeQuery transversalTypeQuery = new ContentTypeQuery(SkillEditionFunction.TRANSVERSAL_MACRO_SKILL_TYPE); 236 // Join the acquired macro skills of the program to the ids of the macroSkills 237 Query transversalUsedInProgramQuery = new JoinQuery(programQuery, 238 // Join key inverted to retrieve the macroSkills whose ids match the ids in the program attribute "transversalSkills" 239 new JoinKey(SolrFieldNames.ID_DV, Program.TRANSVERSAL_SKILLS, null)); 240 Query validTransversalSkillQuery = new AndQuery(transversalTypeQuery, transversalUsedInProgramQuery); 241 242 243 ContentTypeQuery disciplinaryTypeQuery = new ContentTypeQuery(SkillEditionFunction.DISCIPLINARY_MACRO_SKILL_TYPE); 244 StringQuery parentProgramQuery = new StringQuery("parentProgram"); 245 Query nonOrphanDisciplinarySkillQuery = new AndQuery(disciplinaryTypeQuery, parentProgramQuery); 246 // Join the acquired macro skills of the program to the ids of the macroSkills 247 Query disciplinaryUsedInProgramQuery = new JoinQuery(programQuery, 248 // Join key inverted to retrieve the macroSkills whose ids match the ids in the program attribute "skills" 249 new JoinKey(SolrFieldNames.ID_DV, Program.SKILLS, null)); 250 Query validDisciplinarySkillQuery = new AndQuery(nonOrphanDisciplinarySkillQuery, disciplinaryUsedInProgramQuery); 251 252 OrQuery usedMacroSkill = new OrQuery(validTransversalSkillQuery, validDisciplinarySkillQuery); 253 return _getSkills(escapedQuery, catalogQuery, lang, limit, rightCheckingMode, usedMacroSkill); 254 } 255 256 /** 257 * Get the non orphan micro skills matching the query 258 * @param escapedQuery the query 259 * @param catalogQuery The catalog 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> _getMicroSkills(String escapedQuery, Query catalogQuery, String lang, int limit, RightCheckingMode rightCheckingMode) throws Exception 267 { 268 // Search for micro skills that are not orphans (Micro with a parent macro) 269 ContentTypeQuery microTypeQuery = new ContentTypeQuery(SkillEditionFunction.MICRO_SKILL_TYPE); 270 StringQuery nonOprhanMicroQuery = new StringQuery("parentMacroSkill"); 271 Query nonOrphanMicroSkillQuery = new AndQuery(microTypeQuery, nonOprhanMicroQuery); 272 273 Query programQuery = new AndQuery(catalogQuery, new ContentTypeQuery(ProgramFactory.PROGRAM_CONTENT_TYPE)); 274 275 // Join the acquired micro skills of the program to the ids of the microSkills 276 Query usedInProgramQuery = new JoinQuery(programQuery, 277 // Join key inverted to retrieve the microSkills whose ids match the ids in the program property "computedMicroSkills" 278 new JoinKey(SolrFieldNames.ID_DV, Program.COMPUTED_MICRO_SKILLS, null)); 279 280 Query usedMicroSkillsQuery = new AndQuery(nonOrphanMicroSkillQuery, usedInProgramQuery); 281 return _getSkills(escapedQuery, catalogQuery, lang, limit, rightCheckingMode, usedMicroSkillsQuery); 282 } 283 284 /** 285 * Get the skills contents matching the query 286 * @param escapedQuery the query 287 * @param catalogQuery The catalog query 288 * @param lang the language 289 * @param limit the max number of results 290 * @param rightCheckingMode the mode for checking rights 291 * @param typeAndOrphanQuery The query that checks the type of a skill and whether it is an orphan 292 * @return the matching contents 293 * @throws Exception if an error occurred during search 294 */ 295 protected AmetysObjectIterable<Content> _getSkills(String escapedQuery, Query catalogQuery, String lang, int limit, RightCheckingMode rightCheckingMode, Query typeAndOrphanQuery) throws Exception 296 { 297 // Retrieve the macro and micro skills that match the request 298 Searcher searcher = _searcherFactory.create() 299 .withQuery(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true)) 300 .addFilterQuery(new DocumentTypeQuery(SolrFieldNames.TYPE_CONTENT)) 301 .addFilterQuery(typeAndOrphanQuery) 302 .addFilterQuery(catalogQuery) 303 .withLimits(0, limit); 304 305 _setRightCheckingMode(searcher, rightCheckingMode); 306 307 return searcher.search(); 308 } 309 310 /** 311 * Get the JSON representation of a page hit 312 * @param content the content 313 * @param siteName the current site name 314 * @param odfRootPage the ODF root page. Can be null if search is configured for multiple catalogs 315 * @return the page as json 316 */ 317 protected Map<String, Object> _getPageHit(Content content, String siteName, Page odfRootPage) 318 { 319 Page page = null; 320 if (content instanceof Program) 321 { 322 page = odfRootPage != null ? _odfPageResolver.getProgramPage(odfRootPage, (Program) content) : _odfPageResolver.getProgramPage((Program) content, siteName); 323 } 324 else if (content instanceof SubProgram) 325 { 326 page = odfRootPage != null ? _odfPageResolver.getSubProgramPage(odfRootPage, (SubProgram) content, null) : _odfPageResolver.getSubProgramPage((SubProgram) content, null, siteName); 327 } 328 else if (content instanceof Course) 329 { 330 page = odfRootPage != null ? _odfPageResolver.getCoursePage(odfRootPage, (Course) content, (AbstractProgram) null) : _odfPageResolver.getCoursePage((Course) content, (AbstractProgram) null, siteName); 331 } 332 333 if (page != null) 334 { 335 Map<String, Object> result = new HashMap<>(); 336 result.put("title", page.getTitle()); 337 338 RenderingContext context = _renderingContextHandler.getRenderingContext(); 339 if (!(context == RenderingContext.BACK)) 340 { 341 StringBuilder uri = new StringBuilder(); 342 uri.append(_prefixHandler.getUriPrefix(siteName)); 343 uri.append("/"); 344 uri.append(page.getSitemapName() + "/" + page.getPathInSitemap() + ".html"); 345 346 result.put("url", URIUtils.encodePath(uri.toString())); 347 } 348 else // back 349 { 350 result.put("url", "javascript:(function(){parent.Ametys.tool.ToolsManager.openTool('uitool-page', {id:'" + page.getId() + "'});})()"); 351 } 352 353 return result; 354 } 355 356 return null; 357 } 358 359 /** 360 * Get the JSON representation of a content hit 361 * @param content the content 362 * @return the content as json 363 */ 364 protected Map<String, Object> _getContentHit(Content content) 365 { 366 Map<String, Object> result = new HashMap<>(); 367 368 result.put("title", content.getTitle()); 369 result.put("id", content.getId()); 370 371 return result; 372 } 373 374}