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}