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}