001/*
002 *  Copyright 2024 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.web.frontoffice.search.fast;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023
024import org.apache.avalon.framework.parameters.Parameters;
025import org.apache.avalon.framework.service.ServiceException;
026import org.apache.avalon.framework.service.ServiceManager;
027import org.apache.cocoon.ProcessingException;
028import org.apache.cocoon.acting.ServiceableAction;
029import org.apache.cocoon.environment.ObjectModelHelper;
030import org.apache.cocoon.environment.Redirector;
031import org.apache.cocoon.environment.Request;
032import org.apache.cocoon.environment.SourceResolver;
033import org.apache.commons.lang.StringUtils;
034
035import org.ametys.cms.content.indexing.solr.SolrFieldNames;
036import org.ametys.cms.search.query.AndQuery;
037import org.ametys.cms.search.query.Query;
038import org.ametys.cms.search.query.Query.Operator;
039import org.ametys.cms.search.query.QueryHelper;
040import org.ametys.cms.search.query.StringQuery;
041import org.ametys.cms.search.solr.SearcherFactory;
042import org.ametys.cms.search.solr.SearcherFactory.Searcher;
043import org.ametys.core.cocoon.JSonReader;
044import org.ametys.plugins.repository.AmetysObject;
045import org.ametys.plugins.repository.AmetysObjectIterable;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
048import org.ametys.web.URIPrefixHandler;
049import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
050import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager;
051import org.ametys.web.frontoffice.search.instance.model.RightCheckingMode;
052import org.ametys.web.frontoffice.search.requesttime.impl.SearchComponentHelper;
053import org.ametys.web.renderingcontext.RenderingContextHandler;
054import org.ametys.web.repository.page.SitemapElement;
055import org.ametys.web.repository.page.ZoneItem;
056import org.ametys.web.repository.site.Site;
057import org.ametys.web.repository.site.SiteManager;
058
059/**
060 * Abstract to get the proposed ametys object while beginning a search from a given search service
061 */
062public abstract class AbstractAutocompletionSearchServiceAction extends ServiceableAction
063{
064    /** The default max number of results */
065    protected static final int NB_MAX_RESULTS = 10;
066
067    /** The search factory */
068    protected SearcherFactory _searcherFactory;
069
070    /** Component for search service */
071    protected SearchServiceInstanceManager _searchServiceInstanceManager;
072
073    /** The search component helper */
074    protected SearchComponentHelper _searchComponentHelper;
075    
076    /** The ametys object resolver */
077    protected AmetysObjectResolver _resolver;
078    
079    /** The site manager */
080    protected SiteManager _siteManager;
081    
082    /** The prefix handler */
083    protected URIPrefixHandler _prefixHandler;
084
085    /** The rendering context handler */
086    protected RenderingContextHandler _renderingContextHandler;
087    
088    @Override
089    public void service(ServiceManager serviceManager) throws ServiceException
090    {
091        super.service(serviceManager);
092        _searcherFactory = (SearcherFactory) serviceManager.lookup(SearcherFactory.ROLE);
093        _searchServiceInstanceManager = (SearchServiceInstanceManager) serviceManager.lookup(SearchServiceInstanceManager.ROLE);
094        _prefixHandler = (URIPrefixHandler) serviceManager.lookup(URIPrefixHandler.ROLE);
095        _renderingContextHandler = (RenderingContextHandler) serviceManager.lookup(RenderingContextHandler.ROLE);
096        _searchComponentHelper = (SearchComponentHelper) serviceManager.lookup(SearchComponentHelper.ROLE);
097        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
098        _siteManager = (SiteManager) serviceManager.lookup(SiteManager.ROLE);
099    }
100    
101    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
102    {
103        Request request = ObjectModelHelper.getRequest(objectModel);
104        
105        Site site = _getSite(request);
106        String lang = _getLang(request);
107        String zoneItemId = _getZoneItemId(request);
108        
109        RightCheckingMode rightCheckingMode = getRightCheckingMode(zoneItemId);
110        int limit = request.getParameter("limit") != null ? Integer.valueOf(request.getParameter("limit")) : NB_MAX_RESULTS;
111        
112        // Retrieve current workspace
113        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
114        try
115        {
116            String query = request.getParameter("q");
117            String escapedQuery = _escapeQuery(query);
118            
119            Map<String, Object> results = _searchAmetysObject(site, zoneItemId, lang, escapedQuery, limit, rightCheckingMode);
120            
121            request.setAttribute(JSonReader.OBJECT_TO_READ, results);
122            return EMPTY_MAP;
123        }
124        catch (Exception e)
125        {
126            getLogger().error("Error getting auto-complete list.", e);
127            throw new ProcessingException("Error getting auto-complete list.", e);
128        }
129        finally
130        {
131            // Restore context
132            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
133        }
134    }
135    
136    /**
137     * Search ametys object and set the results
138     * @param site the site
139     * @param zoneItemId the zone item id
140     * @param lang the lang 
141     * @param escapedQuery the escaped query
142     * @param limit the limit
143     * @param rightCheckingMode the right checking mode
144     * @return the search results
145     * @throws Exception if an error occurred during search
146     */
147    protected Map<String, Object>  _searchAmetysObject(Site site, String zoneItemId, String lang, String escapedQuery, int limit, RightCheckingMode rightCheckingMode) throws Exception
148    {
149        Map<String, Object> results = new HashMap<>();
150        
151        // Search on content's title with the given content type
152        AmetysObjectIterable<AmetysObject> searchHits = _search(site, zoneItemId, lang, escapedQuery, limit, rightCheckingMode);
153        
154        setHitsInResults(results, searchHits, site, zoneItemId, lang);
155        
156        return results;
157    }
158    
159    /**
160     * Set the hits in the results
161     * @param results the results
162     * @param searchHits the search hits
163     * @param site the site
164     * @param zoneItemId the zone item it
165     * @param lang the lang
166     */
167    protected abstract void setHitsInResults(Map<String, Object> results, AmetysObjectIterable<AmetysObject> searchHits, Site site, String zoneItemId, String lang);
168
169    /**
170     * Get the ametys object matching the query
171     * @param site the site
172     * @param zoneItemId the zone item it
173     * @param lang the language
174     * @param escapedQuery the query
175     * @param limit the max number of results
176     * @param rightCheckingMode the mode for checking rights
177     * @return the matching ametys object
178     * @throws Exception if an error occurred during search
179     */
180    protected AmetysObjectIterable<AmetysObject> _search(Site site, String zoneItemId, String lang, String escapedQuery, int limit, RightCheckingMode rightCheckingMode) throws Exception
181    {
182        SearchServiceInstance searchService = _searchServiceInstanceManager.get(zoneItemId);
183        SitemapElement page = _getSitemapElement(zoneItemId);
184        
185        List<Query> queries = new ArrayList<>();
186        queries.add(new StringQuery(SolrFieldNames.TITLE, Operator.SEARCH, escapedQuery, lang, true));
187        queries.add(_searchComponentHelper.getCriterionTreeQuery(searchService, Map.of(), site, lang));
188        
189        Searcher searcher = _searcherFactory.create()
190                .withQuery(new AndQuery(queries))
191                .addFilterQuery(_searchComponentHelper.getFilterQuery(searchService, site, page, lang))
192                .withLimits(0, limit);
193        
194        _setRightCheckingMode(searcher, rightCheckingMode);
195
196        return searcher.search();
197    }
198    
199    /**
200     * Set whether to check rights when searching,
201     * @param searcher the searcher
202     * @param rightCheckingMode the the mode for checking rights
203     */
204    protected void _setRightCheckingMode(Searcher searcher, RightCheckingMode rightCheckingMode)
205    {
206        switch (rightCheckingMode)
207        {
208            case EXACT:
209            case FAST:
210                // FAST will be force to EXACT because of user inputs
211                searcher.setCheckRights(true);
212                break;
213            case NONE:
214                searcher.setCheckRights(false);
215                break;
216            default:
217                throw new IllegalStateException("Unhandled right checking mode: " + rightCheckingMode);
218        }
219    }
220
221    private SitemapElement _getSitemapElement(String zoneItemId)
222    {
223        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
224        AmetysObject page = zoneItem.getParent();
225        while (!(page instanceof SitemapElement sitemapElement))
226        {
227            page = page.getParent();
228        }
229        
230        return (SitemapElement) page;
231    }
232
233    private String _getZoneItemId(Request request)
234    {
235        String zoneItemId = request.getParameter("zoneItemId");
236        if (StringUtils.isEmpty(zoneItemId))
237        {
238            throw new IllegalArgumentException("A zone item identifier must be specified for auto completion");
239        }
240        return zoneItemId;
241    }
242
243    private String _getLang(Request request)
244    {
245        String lang = request.getParameter("lang");
246        if (StringUtils.isEmpty(lang))
247        {
248            throw new IllegalArgumentException("A language must be specified for auto completion");
249        }
250        return lang;
251    }
252
253    private Site _getSite(Request request)
254    {
255        String siteName = request.getParameter("siteName");
256        if (StringUtils.isEmpty(siteName))
257        {
258            throw new IllegalArgumentException("A site must be specified for auto completion");
259        }
260        return _siteManager.getSite(siteName);
261    }
262    
263    /**
264     * Get the mode for checking rights
265     * @param zoneItemId the id of zone item
266     * @return the mode for checking rights
267     */
268    protected RightCheckingMode getRightCheckingMode(String zoneItemId)
269    {
270        return Optional.ofNullable(zoneItemId)
271            .filter(StringUtils::isNotEmpty)
272            .map(s -> _searchServiceInstanceManager.get(zoneItemId))
273            .map(SearchServiceInstance::getRightCheckingMode)
274            .orElse(RightCheckingMode.EXACT);
275    }
276    
277    private String _escapeQuery(String text)
278    {
279        String trimText = StringUtils.strip(text.trim(), "*");
280        return "*" + QueryHelper.escapeQueryCharsExceptStarsAndWhitespaces(trimText) + "*";
281    }
282}