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 */
016package org.ametys.plugins.ai.rest;
017
018import java.time.ZoneId;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Locale;
024import java.util.Map;
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.acting.ServiceableAction;
030import org.apache.cocoon.environment.ObjectModelHelper;
031import org.apache.cocoon.environment.Redirector;
032import org.apache.cocoon.environment.Request;
033import org.apache.cocoon.environment.SourceResolver;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.cms.contenttype.ContentAttributeDefinition;
037import org.ametys.cms.data.ContentValue;
038import org.ametys.cms.data.type.ModelItemTypeConstants;
039import org.ametys.cms.search.model.SearchModel;
040import org.ametys.cms.search.ui.model.SearchModelCriterionViewItem;
041import org.ametys.cms.search.ui.model.SearchUIModel;
042import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint;
043import org.ametys.core.DevMode;
044import org.ametys.core.DevMode.DEVMODE;
045import org.ametys.core.cocoon.JSonReader;
046import org.ametys.core.util.DateUtils;
047import org.ametys.core.util.I18nizableSerializer;
048import org.ametys.core.util.language.LocaleHelper;
049import org.ametys.plugins.core.ui.ObfuscatedException;
050import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
051import org.ametys.plugins.repository.metadata.MultilingualString;
052import org.ametys.plugins.repository.metadata.MultilingualStringHelper;
053import org.ametys.plugins.repository.model.RepeaterViewItem;
054import org.ametys.runtime.model.ElementDefinition;
055import org.ametys.runtime.model.ViewElement;
056import org.ametys.runtime.model.ViewElementAccessor;
057import org.ametys.runtime.model.ViewItemAccessor;
058import org.ametys.runtime.model.ViewItemContainer;
059import org.ametys.runtime.model.ViewItemGroup;
060import org.ametys.runtime.model.type.DataContext;
061import org.ametys.runtime.model.type.ModelItemType;
062import org.ametys.web.WebHelper;
063
064/**
065 * Action that handle operation related to search
066 */
067public abstract class AbstractSearchAction extends ServiceableAction
068{
069    /** The locale helper */
070    protected LocaleHelper _localeHelper;
071    
072    /** The search model extension point */
073    protected SearchUIModelExtensionPoint _searchModelEP;
074    
075    @Override
076    public void service(ServiceManager serviceManager) throws ServiceException
077    {
078        super.service(serviceManager);
079        _localeHelper = (LocaleHelper) serviceManager.lookup(LocaleHelper.ROLE);
080        _searchModelEP = (SearchUIModelExtensionPoint) serviceManager.lookup(SearchUIModelExtensionPoint.ROLE);
081    }
082    
083    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
084    {
085        Request request = ObjectModelHelper.getRequest(objectModel);
086        try
087        {
088            String siteName = WebHelper.getSiteName(request);
089            String modelName = parameters.getParameter("modelName");
090            if (StringUtils.isBlank(siteName))
091            {
092                Map<String, Object> errorMap = Map.of(
093                    "error", true,
094                    "message", "Search can't be used outside of a site context"
095                );
096                request.setAttribute(JSonReader.OBJECT_TO_READ, errorMap);
097                return EMPTY_MAP;
098            }
099            
100            if (!(_searchModelEP.hasExtension(modelName) && _searchModelEP.getExtension(modelName).hasTag(SearchUIModel.TAG_PUBLIC)))
101            {
102                Map<String, Object> errorMap = Map.of(
103                        "error", true,
104                        "message", "The tool '" + modelName + "' is not valid.");
105                request.setAttribute(JSonReader.OBJECT_TO_READ, errorMap);
106                return EMPTY_MAP;
107            }
108            
109            Map<String, Object> contextualParameters = new HashMap<>();
110            contextualParameters.put("siteName", siteName); // required by SiteQuery
111            
112            String lang = request.getParameter("lang");
113            if (StringUtils.isBlank(lang))
114            {
115                lang = _localeHelper.findLocale(objectModel).getLanguage();
116            }
117            else
118            {
119                request.setAttribute(I18nizableSerializer.REQUEST_ATTR_LOCALE, lang);
120            }
121            
122            contextualParameters.put("language", lang);
123            
124            
125            SearchUIModel searchModel = _searchModelEP.getExtension(modelName);
126            return doAct(request, searchModel, contextualParameters);
127        }
128        catch (Exception e)
129        {
130            Exception obfuscatedException = _obfuscateAndLog(e);
131            request.setAttribute(JSonReader.OBJECT_TO_READ, Map.of("error", obfuscatedException.toString()));
132        }
133        return EMPTY_MAP;
134    }
135    
136    /**
137     * Actual operation
138     * @param request the request
139     * @param searchModel the requested search model
140     * @param contextualParameters a map of contextual parameters
141     * @return the action result
142     * @throws Exception if an error occurs
143     */
144    protected abstract Map doAct(Request request, SearchUIModel searchModel, Map<String, Object> contextualParameters) throws Exception;
145    
146    /**
147     * Determine if an accessor can be flatened inside a parent accessor
148     * ie, the accessor contains a single item that is a leaf with a name not already present in the parent
149     * @param accessor the accessor to test
150     * @param parent the parent of the accessor
151     * @return true if the hierarchy of this accessor can be flatened in the parent
152     */
153    protected boolean _canBeFlattened(ViewItemAccessor accessor, ViewItemAccessor parent)
154    {
155        // contains a single column
156        if (accessor.getViewItems().size() == 1
157            && accessor.getViewItems().getFirst() instanceof ViewElement child)
158        {
159            // the name of this single items is not conflicting with anything else in the view
160            if (!parent.hasModelViewItem(child.getName()) || parent.getViewItem(child.getName()).equals(accessor))
161            {
162                // support only repeater or content
163                return accessor instanceof RepeaterViewItem
164                    || accessor instanceof ViewElementAccessor element &&  element.getDefinition() instanceof ContentAttributeDefinition;
165            }
166        }
167        return false;
168    }
169    
170    /**
171     * Get the criteria as a list instead of tree
172     * @param searchModel the search model
173     * @param contextualParameters the contextual parameters
174     * @return the list of criteria
175     */
176    protected List<SearchModelCriterionViewItem> getCriteria(SearchModel searchModel, Map<String, Object> contextualParameters)
177    {
178        ViewItemContainer criteria = searchModel.getCriteria(contextualParameters);
179        // For static search model simple criteria are gathered by groups with a last group for all the criteria without groups
180        if (criteria.getViewItems().size() == 1 && criteria.getViewItems().get(0) instanceof ViewItemGroup noGroupContainer)
181        {
182            criteria = noGroupContainer;
183        }
184        
185        try
186        {
187            return criteria.getViewItems().stream()
188                    .map(SearchModelCriterionViewItem.class::cast)
189                    .toList();
190        }
191        catch (ClassCastException e)
192        {
193            throw new UnsupportedOperationException("Search model with group are not supported (" + searchModel.getId() + ")");
194        }
195    }
196    
197    /**
198     * Serialize as JSON the value of a data holder element
199     * @param dataHolder the data holder containing the data
200     * @param definition the definition of the element to serialize
201     * @param dataContext the data context of the data holder
202     * @param contextualParameters the contextual parameters
203     * @return the JSON value
204     */
205    protected Object _elementToJson(ModelAwareDataHolder dataHolder, ElementDefinition definition, DataContext dataContext, Map<String, Object> contextualParameters)
206    {
207        DataContext localContext = dataContext.cloneContext()
208                .addSegmentToDataPath(definition.getName());
209        Object value = dataHolder.getValue(definition.getName());
210        ModelItemType type = definition.getType();
211        Object itemJson;
212        switch (type.getId())
213        {
214            case org.ametys.plugins.repository.data.type.ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID:
215                itemJson = _value2Json(definition, value, this::_multilingual2Json, localContext, contextualParameters);
216                break;
217            case org.ametys.runtime.model.type.ModelItemTypeConstants.DATETIME_TYPE_ID:
218                itemJson = _value2Json(definition, value, this::_dateTime2Json, dataContext, contextualParameters);
219                break;
220            case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID:
221                itemJson = _value2Json(definition, value, this::_file2Json, localContext, contextualParameters);
222                break;
223            case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID:
224                itemJson = _value2Json(definition, value, this::_contentAttribute2Json, localContext, contextualParameters);
225                break;
226            case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID:
227                itemJson = _value2Json(definition, value, this::_richtext2Json, localContext, contextualParameters);
228                break;
229            default:
230                itemJson = type.valueToJSONForClient(value, localContext);
231        }
232        return itemJson;
233    }
234    
235    /* handle cardinality */
236    private Object _value2Json(ElementDefinition definition, Object value, ValueConverter singleValueConverter, DataContext dataContext, Map<String, Object> contextualParameters)
237    {
238        if (value == null)
239        {
240            return null;
241        }
242        
243        if (definition.isMultiple())
244        {
245            List<Object> result = new ArrayList<>();
246            for (Object v : (Object[]) value)
247            {
248                Object jsonValue = singleValueConverter.convert(v, definition, dataContext, contextualParameters);
249                if (jsonValue != null)
250                {
251                    result.add(jsonValue);
252                }
253            }
254            return result.isEmpty() ? null : result;
255        }
256        else
257        {
258            return singleValueConverter.convert(value, definition, dataContext, contextualParameters);
259        }
260    }
261    
262    /* Functional interface to ease the understanding */
263    private interface ValueConverter
264    {
265        public Object convert(Object value, ElementDefinition definition, DataContext dataContext, Map<String, Object> contextualParameters);
266    }
267    
268    private Object _multilingual2Json(Object value, @SuppressWarnings("unused") ElementDefinition definition, @SuppressWarnings("unused") DataContext dataContext, Map<String, Object> contextualParameters)
269    {
270        MultilingualString multilingualString = (MultilingualString) value;
271        Locale closestNonEmptyLocale = MultilingualStringHelper.getClosestNonEmptyLocale(multilingualString, Locale.of((String) contextualParameters.get("language")));
272        return multilingualString.getValue(closestNonEmptyLocale);
273    }
274    
275    private Object _dateTime2Json(Object value, @SuppressWarnings("unused") ElementDefinition definition, @SuppressWarnings("unused") DataContext dataContext, @SuppressWarnings("unused") Map<String, Object> contextualParameters)
276    {
277        ZonedDateTime dateTime = (ZonedDateTime) value;
278        // Force the "local" zone because user values are interpreted as local by the criteria
279        // see DateTimeQuery::appendDateValue
280        return DateUtils.zonedDateTimeToString(dateTime, ZoneId.systemDefault());
281    }
282
283    private Object _file2Json(Object value, ElementDefinition definition, DataContext dataContext, @SuppressWarnings("unused") Map<String, Object> contextualParameters)
284    {
285        @SuppressWarnings("unchecked")
286        Map<String, Object> json = (Map<String, Object>) definition.getType().valueToJSONForClient(value, dataContext);
287        if (json != null)
288        {
289            return json.get("viewUrl");
290        }
291        return null;
292    }
293
294    private Object _contentAttribute2Json(Object value, @SuppressWarnings("unused") ElementDefinition definition, @SuppressWarnings("unused") DataContext dataContext, @SuppressWarnings("unused") Map<String, Object> contextualParameters)
295    {
296        Locale locale = contextualParameters.containsKey("language") ? Locale.of((String) contextualParameters.get("language")) : null;
297        return ((ContentValue) value).getContentIfExists()
298                .map(c -> c.getTitle(locale))
299                .orElse(null);
300    }
301
302    private Object _richtext2Json(Object value, ElementDefinition definition, DataContext dataContext, @SuppressWarnings("unused") Map<String, Object> contextualParameters)
303    {
304        @SuppressWarnings("unchecked")
305        Map<String, Object> json = (Map<String, Object>) definition.getType().valueToJSONForClient(value, dataContext);
306        if (json != null)
307        {
308            return json.get("value");
309        }
310        return null;
311    }
312    
313    private Exception _obfuscateAndLog(Exception e)
314    {
315        // Do not read dev mode from request to prevent override from request param
316        if (!DEVMODE.PRODUCTION.equals(DevMode.getDeveloperMode()))
317        {
318            ObfuscatedException obfuscated = ObfuscatedException.obfuscate(e);
319            obfuscated.reveal();
320            getLogger().error("An error occured while searching for content", obfuscated);
321            obfuscated.obfuscate();
322            return obfuscated;
323        }
324        getLogger().error("An error occured while searching for content", e);
325        return e;
326    }
327}