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}