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}