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}