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.Optional;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.parameters.Parameters;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.cocoon.ProcessingException;
030import org.apache.cocoon.acting.ServiceableAction;
031import org.apache.cocoon.environment.ObjectModelHelper;
032import org.apache.cocoon.environment.Redirector;
033import org.apache.cocoon.environment.Request;
034import org.apache.cocoon.environment.SourceResolver;
035import org.apache.commons.lang.StringUtils;
036import org.apache.solr.client.solrj.util.ClientUtils;
037
038import org.ametys.cms.content.ContentHelper;
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.ContentQuery;
045import org.ametys.cms.search.query.ContentTypeQuery;
046import org.ametys.cms.search.query.DocumentTypeQuery;
047import org.ametys.cms.search.query.OrQuery;
048import org.ametys.cms.search.query.Query;
049import org.ametys.cms.search.query.Query.Operator;
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.cms.transformation.xslt.ResolveURIComponent;
054import org.ametys.core.cocoon.JSonReader;
055import org.ametys.odf.ProgramItem;
056import org.ametys.odf.enumeration.OdfReferenceTableHelper;
057import org.ametys.odf.program.ProgramFactory;
058import org.ametys.plugins.repository.AmetysObjectIterable;
059import org.ametys.plugins.repository.AmetysObjectResolver;
060import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
061import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
062import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager;
063import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode;
064import org.ametys.web.indexing.solr.SolrWebFieldNames;
065import org.ametys.web.repository.page.Page;
066import org.ametys.web.search.query.PageContentQuery;
067import org.ametys.web.search.query.SiteQuery;
068import org.ametys.web.search.query.SitemapQuery;
069
070/**
071 * Get the proposed program's pages and skills for auto-completion while beginning a search
072 */
073public class AutocompletionSearchAction extends ServiceableAction
074{
075    /** The default max number of results */
076    protected static final int NB_MAX_RESULTS = 10;
077
078    /** The search factory */
079    protected SearcherFactory _searcherFactory;
080
081    /** Component for search service */
082    protected SearchServiceInstanceManager _searchServiceInstanceManager;
083
084    private ContentHelper _contentHelper;
085
086    private AmetysObjectResolver _resolver;
087    
088    
089    @Override
090    public void service(ServiceManager serviceManager) throws ServiceException
091    {
092        super.service(serviceManager);
093        _searcherFactory = (SearcherFactory) serviceManager.lookup(SearcherFactory.ROLE);
094        _searchServiceInstanceManager = (SearchServiceInstanceManager) serviceManager.lookup(SearchServiceInstanceManager.ROLE);
095        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
096        _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE);
097    }
098    
099    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
100    {
101        Request request = ObjectModelHelper.getRequest(objectModel);
102        
103        String siteName = request.getParameter("siteName");
104        if (StringUtils.isEmpty(siteName))
105        {
106            throw new IllegalArgumentException("A site must be specified for auto completion");
107        }
108        
109        String lang = request.getParameter("lang");
110        if (StringUtils.isEmpty(lang))
111        {
112            throw new IllegalArgumentException("A language must be specified for auto completion");
113        }
114        
115        String zoneItemId = request.getParameter("zoneItemId");
116        List<String> catalogNames = getCatalogNames(zoneItemId);
117        
118        int limit = request.getParameter("limit") != null ? Integer.valueOf(request.getParameter("limit")) : NB_MAX_RESULTS; 
119        
120        // Retrieve current workspace
121        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
122        
123        String contentType = parameters.getParameter("contentType", ProgramFactory.PROGRAM_CONTENT_TYPE);
124        
125        try
126        {
127            String query = request.getParameter("q");
128            String escapedQuery = _escapeQuery(query);
129            
130            Map<String, Object> results = new HashMap<>();
131            
132            // Search on page's title with the given content type
133            AmetysObjectIterable<Page> pageResults = getContentPages(contentType, siteName, lang, catalogNames, escapedQuery, limit);
134            List<Map<String, Object>> pages = pageResults.stream()
135                    .map(this::getPageHit)
136                    .collect(Collectors.toList());
137            results.put("pages", pages);
138            
139            // Search on skill's title
140            AmetysObjectIterable<Content> skillResults = getSkills(escapedQuery, lang, limit);
141            List<Map<String, Object>> skills = skillResults.stream()
142                    .map(this::getContentHit)
143                    .collect(Collectors.toList());
144            
145            results.put("skills", skills);
146            
147            request.setAttribute(JSonReader.OBJECT_TO_READ, results);
148            return EMPTY_MAP;
149        }
150        catch (Exception e)
151        {
152            getLogger().error("Error getting auto-complete list.", e);
153            throw new ProcessingException("Error getting auto-complete list.", e);
154        }
155        finally
156        {
157            // Restore context
158            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
159        }
160    }
161    
162    /**
163     * Get the configured catalog in search criteria
164     * @param zoneItemId The id of zone item
165     * @return the catalog's name
166     */
167    protected List<String> getCatalogNames(String zoneItemId)
168    {
169        if (StringUtils.isNotEmpty(zoneItemId))
170        {
171            SearchServiceInstance serviceInstance = _searchServiceInstanceManager.get(zoneItemId);
172            
173            List<String> catalogValues = serviceInstance.getCriterionTree()
174                .map(AbstractTreeNode::getFlatLeaves)
175                .orElseGet(Collections::emptyList)
176                .stream()
177                .map(TreeLeaf::getValue)
178                .filter(c -> c.getMode() == FOSearchCriterionMode.STATIC)
179                .filter(c -> StringUtils.endsWith(c.getCriterionDefinition().getId(), "$indexingField$org.ametys.plugins.odf.Content.programItem$catalog"))
180                .map(c -> c.getStaticValue())
181                .filter(Optional::isPresent)
182                .map(Optional::get)
183                .map(String.class::cast)
184                .collect(Collectors.toList());
185            
186            return catalogValues;
187        }
188        else
189        {
190            return List.of();
191        }
192        
193    }
194    
195    /**
196     * Get the content pages matching the query
197     * @param cType The content type of pages to search
198     * @param siteName the site name
199     * @param lang the language
200     * @param catalogNames The name of catalog to take into account. Can be empty
201     * @param escapedQuery the query
202     * @param limit the max number of results
203     * @return the matching pages
204     * @throws Exception if an error occurred during search
205     */
206    protected AmetysObjectIterable<Page> getContentPages(String cType, String siteName, String lang, List<String> catalogNames, String escapedQuery, int limit) throws Exception
207    {
208        List<Query> queries = new ArrayList<>();
209        queries.add(new StringQuery(SolrWebFieldNames.PAGE_TITLE, Operator.SEARCH, escapedQuery, lang, true));
210        
211        if (!catalogNames.isEmpty())
212        {
213            List<Query> joinQueries = new ArrayList<>();
214            for (String catalogName : catalogNames)
215            {
216                joinQueries.add(new PageContentQuery(new ContentQuery(ProgramItem.CATALOG, Operator.EQ, catalogName, _resolver, _contentHelper)));
217            }
218            queries.add(new OrQuery(joinQueries));
219        }
220        
221        Searcher searcher = _searcherFactory.create()
222                .withQuery(new AndQuery(queries))
223                .addFilterQuery(new DocumentTypeQuery(SolrWebFieldNames.TYPE_PAGE))
224                .addFilterQuery(new SiteQuery(siteName))
225                .addFilterQuery(new SitemapQuery(lang))
226                .addFilterQueryString(SolrWebFieldNames.PAGE_CONTENT_TYPES + ":" + cType)
227                .withLimits(0, limit)
228                .setCheckRights(true);
229
230        return searcher.search();
231    }
232    
233    /**
234     * Get the skills contents matching the query
235     * @param escapedQuery the query
236     * @param lang the language
237     * @param limit the max number of results
238     * @return the matching contents
239     * @throws Exception if an error occurred during search
240     */
241    protected AmetysObjectIterable<Content> getSkills(String escapedQuery, String lang, int limit) throws Exception
242    {
243        Searcher searcher = _searcherFactory.create()
244                .withQuery(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true))
245                .addFilterQuery(new DocumentTypeQuery(SolrFieldNames.TYPE_CONTENT))
246                .addFilterQuery(new ContentTypeQuery(OdfReferenceTableHelper.SKILL))
247                .withLimits(0, limit)
248                .setCheckRights(true);
249
250        return searcher.search();
251    }
252    
253    private String _escapeQuery(String text)
254    {
255        String trimText = StringUtils.strip(text.trim(), "*");
256        return "*" + ClientUtils.escapeQueryChars(trimText) + "*";
257    }
258    
259    /**
260     * Get the JSON representation of a page hit
261     * @param page the page
262     * @return the page as json
263     */
264    protected Map<String, Object> getPageHit(Page page)
265    {
266        Map<String, Object> result = new HashMap<>();
267        
268        result.put("title", page.getTitle());
269        result.put("id", page.getId());
270        result.put("url", ResolveURIComponent.resolve("page", page.getId()));
271        
272        return result;
273    }
274    
275    /**
276     * Get the JSON representation of a content hit
277     * @param content the content
278     * @return the content as json
279     */
280    protected Map<String, Object> getContentHit(Content content)
281    {
282        Map<String, Object> result = new HashMap<>();
283        
284        result.put("title", content.getTitle());
285        result.put("id", content.getId());
286        
287        return result;
288    }
289
290}