001/*
002 * Copyright 2026 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 */
016
017package org.ametys.plugins.ai.rest;
018
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Optional;
025
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.cocoon.environment.Request;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.cms.content.indexing.solr.SolrFieldNames;
032import org.ametys.cms.contenttype.ContentTypesHelper;
033import org.ametys.cms.data.ContentValue;
034import org.ametys.cms.model.CMSDataContext;
035import org.ametys.cms.repository.Content;
036import org.ametys.cms.repository.ModifiableContent;
037import org.ametys.cms.search.SearchResults;
038import org.ametys.cms.search.content.ContentSearcherFactory;
039import org.ametys.cms.search.content.ContentSearcherFactory.SearchModelContentSearcher;
040import org.ametys.cms.search.model.SearchModel;
041import org.ametys.cms.search.model.SearchModelHelper;
042import org.ametys.cms.search.query.FullTextQuery;
043import org.ametys.cms.search.query.Query;
044import org.ametys.cms.search.query.Query.Operator;
045import org.ametys.cms.search.ui.model.SearchModelCriterionViewItem;
046import org.ametys.cms.search.ui.model.SearchUIModel;
047import org.ametys.cms.transformation.URIResolver;
048import org.ametys.cms.transformation.URIResolverExtensionPoint;
049import org.ametys.cms.transformation.xslt.ResolveURIComponent;
050import org.ametys.core.cocoon.JSonReader;
051import org.ametys.plugins.repository.AmetysObjectIterable;
052import org.ametys.plugins.repository.AmetysObjectResolver;
053import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
054import org.ametys.plugins.repository.data.holder.group.Repeater;
055import org.ametys.plugins.repository.data.holder.group.RepeaterEntry;
056import org.ametys.plugins.repository.model.RepeaterDefinition;
057import org.ametys.plugins.repository.model.RepeaterViewItem;
058import org.ametys.runtime.model.ElementDefinition;
059import org.ametys.runtime.model.ViewElement;
060import org.ametys.runtime.model.ViewElementAccessor;
061import org.ametys.runtime.model.ViewItem;
062import org.ametys.runtime.model.ViewItemAccessor;
063import org.ametys.runtime.model.ViewItemContainer;
064import org.ametys.runtime.model.type.DataContext;
065import org.ametys.web.renderingcontext.RenderingContext;
066import org.ametys.web.renderingcontext.RenderingContextHandler;
067import org.ametys.web.repository.content.WebContent;
068import org.ametys.web.repository.page.Page;
069
070/**
071 * The Search Web Service
072 */
073public class GetSolrSearchAction extends AbstractSearchAction
074{
075    /** The request parameter name for highlight.*/
076    private static final String __KEYWORD = "keyword";
077    
078    /** The Ametys object resolver */
079    protected AmetysObjectResolver _ametysObjectResolver;
080    
081    /** The searcher component */
082    protected ContentSearcherFactory _contentSearcherFactory;
083    
084    /** The content types helper */
085    protected ContentTypesHelper _contentTypesHelper;
086    
087    /** The rendering context handler */
088    protected RenderingContextHandler _renderingContextHandler;
089    
090    /** The search model helper */
091    protected SearchModelHelper _searchModelHelper;
092    
093    /** The URI resolver extension point */
094    protected URIResolverExtensionPoint _uriResolverEP;
095    
096    @Override
097    public void service(ServiceManager serviceManager) throws ServiceException
098    {
099        super.service(serviceManager);
100        _ametysObjectResolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
101        _contentSearcherFactory = (ContentSearcherFactory) serviceManager.lookup(ContentSearcherFactory.ROLE);
102        _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
103        _renderingContextHandler = (RenderingContextHandler) serviceManager.lookup(RenderingContextHandler.ROLE);
104        _searchModelHelper = (SearchModelHelper) serviceManager.lookup(SearchModelHelper.ROLE);
105        _uriResolverEP = (URIResolverExtensionPoint) serviceManager.lookup(URIResolverExtensionPoint.ROLE);
106    }
107    
108    @Override
109    protected Map doAct(Request request, SearchUIModel searchModel, Map<String, Object> contextualParameters) throws Exception
110    {
111        RenderingContext currentContext = _renderingContextHandler.getRenderingContext();
112        try
113        {
114            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
115            request.setAttribute("forceAbsoluteUrl", true);
116            
117            Map<String, Object> values = _getValuesFromRequest(request, searchModel, contextualParameters);
118            
119            SearchModelContentSearcher searcher = _contentSearcherFactory.create(searchModel);
120            
121            String keyword = request.getParameter(__KEYWORD);
122            if (StringUtils.isNotBlank(keyword))
123            {
124                // update the lang based on criteria
125                String lang = _searchModelHelper.getCriteriaLanguage(searchModel, "simple", values, contextualParameters);
126                Query highlightQuery = new FullTextQuery(keyword, SolrFieldNames.FULL, lang, Operator.SEARCH_STEMMED);
127                searcher.withHighlightingQuery(highlightQuery);
128            }
129            
130            int returnLimit = searchModel.getPageSize(values);
131            List<Map<String, Object>> finalResults = new ArrayList<>();
132            
133            long total = 0;
134            int fetched = 0;
135            // loop to take into account that some results may be filtered
136            do
137            {
138                searcher.withLimits(fetched, returnLimit);
139                SearchResults<Content> searchResults = searcher.searchWithFacets(values, contextualParameters);
140                
141                total = searchResults.getTotalCount();
142                
143                List<Map<String, Object>> formattedBlock = _formatResults(searchResults, searchModel, contextualParameters);
144                finalResults.addAll(formattedBlock);
145                
146                // result may be filtered during formatting so do not rely on it to determine if further results can be fetched
147                fetched += returnLimit;
148            }
149            while (finalResults.size() < returnLimit && fetched < total); // requested more than currently found && there are still results to be found
150            
151            if (finalResults.size() > returnLimit)
152            {
153                finalResults = new ArrayList<>(finalResults.subList(0, returnLimit));
154            }
155            
156            request.setAttribute(JSonReader.OBJECT_TO_READ, finalResults);
157        }
158        finally
159        {
160            _renderingContextHandler.setRenderingContext(currentContext);
161        }
162        return EMPTY_MAP;
163    }
164    
165    private Map<String, Object> _getValuesFromRequest(Request request, SearchModel searchModel, Map<String, Object> contextualParameters)
166    {
167        Map<String, Object> values = new HashMap<>();
168        
169        for (SearchModelCriterionViewItem item : getCriteria(searchModel, contextualParameters))
170        {
171            String value = request.getParameter(item.getName());
172            if (StringUtils.isNotBlank(value))
173            {
174                values.put(item.getName(), value);
175            }
176        }
177        
178        return values;
179    }
180    
181    private List<Map<String, Object>> _formatResults(SearchResults<Content> searchResults, SearchUIModel searchModel, Map<String, Object> contextualParameters)
182    {
183        ViewItemContainer resultItems = searchModel.getResultItems(contextualParameters);
184        List<Map<String, Object>> formattedResults = new ArrayList<>();
185        boolean requireValidUrl = searchModel.hasTag("require-valid-url");
186        
187        Map<String, Map<String, List<String>>> highlighting = searchResults.getHighlighting();
188        try (AmetysObjectIterable<Content> contents = searchResults.getObjects())
189        {
190            for (Content content : contents)
191            {
192                DataContext dataContext = CMSDataContext.newInstance()
193                        .withObject(content)
194                        .withEmptyValues(false);
195                String url = _getContentURL(content);
196                if (!requireValidUrl || StringUtils.isNotBlank(url))
197                {
198                    Map<String, Object> item = _content2JSON(resultItems, content, contextualParameters, dataContext);
199                    
200                    if (StringUtils.isNotBlank(url))
201                    {
202                        item.put("url", url);
203                    }
204                    if (highlighting != null)
205                    {
206                        Map<String, List<String>> contentHighlighting = highlighting.get(content.getId());
207                        if (contentHighlighting != null)
208                        {
209                            // highlighting are organized by solr field name to avoid needing to transform solr field name
210                            // into ametys data path, we simply put all the highlightings together
211                            List<String> snippets = contentHighlighting.values().stream()
212                                    .flatMap(List::stream)
213                                    .filter(StringUtils::isNotBlank)
214                                    .toList();
215                            if (!snippets.isEmpty())
216                            {
217                                item.put("snippets", snippets);
218                            }
219                        }
220                    }
221                    formattedResults.add(item);
222                }
223            }
224        }
225        return formattedResults;
226    }
227    
228    private Map<String, Object> _content2JSON(ViewItemAccessor resultItems, Content content, Map<String, Object> contextualParameters, DataContext dataContext)
229    {
230        // NOTE can't use content.dataToJSON because it serialize the ViewElementAccessor which return the full content of org.ametys.cms.data.type.BaseContentElementType._content2JsonForClient(Content, DataContext)
231        // when all we want is the content of the accessed view items
232        // can't use searchModelContentValuesExtractor.getValues(content, Locale.of("fr"), contextualParameters);
233        // because MultilingualStringElementType has no option to serialize only the current locale
234        
235        Map<String, Object> json = new LinkedHashMap<>();
236        for (ViewItem viewItem : resultItems.getViewItems())
237        {
238            // Is a leaf
239            if (viewItem instanceof ViewElement element
240                && (!(viewItem instanceof ViewItemAccessor accessor) || accessor.getViewItems().isEmpty()))
241                
242            {
243                ElementDefinition definition = element.getDefinition();
244                if (content.hasValue(definition.getName()))
245                {
246                    json.put(element.getName(), _elementToJson(content, definition, dataContext, contextualParameters));
247                }
248            }
249            // is a content reference containing a single item
250            else if (viewItem instanceof ViewElementAccessor accessor && _canBeFlattened(accessor, resultItems))
251            {
252                if (content.hasValue(accessor.getDefinition().getName()))
253                {
254                    json.putAll(_accessor2Json(content, accessor, contextualParameters, dataContext));
255                }
256            }
257            // is a repeater containing a single item
258            else if (viewItem instanceof RepeaterViewItem repeater && _canBeFlattened(repeater, resultItems))
259            {
260                RepeaterDefinition definition = repeater.getDefinition();
261                if (content.hasValue(definition.getName()))
262                {
263                    Repeater repeaterValue = content.getRepeater(definition.getName());
264                    DataContext localContext = dataContext.cloneContext().addSegmentToDataPath(definition.getName());
265                    ViewElement element = (ViewElement) repeater.getViewItems().getFirst();
266                    
267                    json.putAll(_repeater2Json(repeaterValue, element, contextualParameters, localContext));
268                }
269            }
270            else
271            {
272                throw new UnsupportedOperationException();
273            }
274        }
275        
276        return json;
277    }
278    
279    /**
280     * take a view like <item ref="content/item">
281     * and returns a map like { itemName : [values...]}
282     * where itemName is the name of the child item
283     * and values is the aggregation of all the values of the element referenced by item
284     * in the data holder.
285     * Main difference with a dataToJson is that the structure is simplified
286     * by removing the data holder level and keeping only the child item
287     */
288    private Map<String, Object> _accessor2Json(ModelAwareDataHolder dataHolder, ViewElementAccessor accessor, Map<String, Object> contextualParameters, DataContext dataContext)
289    {
290        ElementDefinition parentDefinition = accessor.getDefinition();
291        DataContext localContext = dataContext.cloneContext().addSegmentToDataPath(parentDefinition.getName());
292        if (parentDefinition.isMultiple())
293        {
294            ContentValue[] values = dataHolder.getValue(parentDefinition.getName());
295            String name = accessor.getViewItems().getFirst().getName();
296            List<Object> resutList = new ArrayList<>();
297            for (ContentValue value : values)
298            {
299                Optional<ModifiableContent> contentIfExists = value.getContentIfExists();
300                if (contentIfExists.isPresent())
301                {
302                    Object itemValue = _content2JSON(accessor, contentIfExists.get(), contextualParameters, localContext).get(name);
303                    if (itemValue instanceof List)
304                    {
305                        @SuppressWarnings("unchecked")
306                        List<Object> listValue = (List<Object>) itemValue;
307                        resutList.addAll(listValue);
308                    }
309                    else
310                    {
311                        resutList.add(itemValue);
312                    }
313                }
314            }
315            
316            if (!resutList.isEmpty())
317            {
318                return Map.of(name, resutList);
319            }
320        }
321        else
322        {
323            Optional<ModifiableContent> value = dataHolder.<ContentValue>getValue(parentDefinition.getName()).getContentIfExists();
324            if (value.isPresent())
325            {
326                return _content2JSON(accessor, value.get(), contextualParameters, localContext);
327            }
328        }
329        
330        return Map.of();
331    }
332    
333    /**
334     * take a view like <item ref="repeater/item">
335     * and returns a map like { itemName : [values...]}
336     * where itemName is the name of the child item
337     * and values is the aggregation of all the values of the element referenced by item
338     * in the repeater entries.
339     * Main difference with a dataToJson is that the structure is simplified
340     * by removing the repeater level and keeping only the child item
341     */
342    private Map<String, List<Object>> _repeater2Json(Repeater repeaterValue, ViewElement element, Map<String, Object> contextualParameters, DataContext dataContext)
343    {
344        List<Object> itemJson = new ArrayList<>();
345        
346        for (RepeaterEntry entry : repeaterValue.getEntries())
347        {
348            Object itemValue = _elementToJson(entry, element.getDefinition(), dataContext, contextualParameters);
349            if (itemValue instanceof List)
350            {
351                @SuppressWarnings("unchecked")
352                List<Object> listValue = (List<Object>) itemValue;
353                itemJson.addAll(listValue);
354            }
355            else
356            {
357                itemJson.add(itemValue);
358            }
359        }
360        
361        if (!itemJson.isEmpty())
362        {
363            return Map.of(element.getName(), itemJson);
364        }
365        
366        return Map.of();
367    }
368    
369    private String _getContentURL(Content content)
370    {
371        if (content instanceof WebContent webContent)
372        {
373            Optional<Page> firstPage = webContent.getReferencingPages().stream().findFirst();
374            if (firstPage.isPresent())
375            {
376                return ResolveURIComponent.resolve("page", firstPage.get().getId(), false, true);
377            }
378        }
379        // Hardcode odf supports
380        else if (_contentTypesHelper.isInstanceOf(content, "org.ametys.plugins.odf.Content.programItem"))
381        {
382            URIResolver resolver = _uriResolverEP.getResolverForType("odf");
383            return resolver.resolve(content.getId(), false, true, false);
384        }
385        
386        return "";
387    }
388}