001/* 002 * Copyright 2018 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.cms.scripts; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.HashSet; 024import java.util.LinkedList; 025import java.util.List; 026import java.util.Map; 027import java.util.Optional; 028import java.util.Set; 029import java.util.stream.Collectors; 030 031import org.apache.avalon.framework.configuration.DefaultConfiguration; 032import org.apache.avalon.framework.configuration.MutableConfiguration; 033import org.apache.avalon.framework.service.ServiceException; 034import org.apache.avalon.framework.service.ServiceManager; 035import org.apache.commons.lang3.StringUtils; 036 037import org.ametys.cms.content.ContentHelper; 038import org.ametys.cms.contenttype.ContentType; 039import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 040import org.ametys.cms.contenttype.ContentTypesHelper; 041import org.ametys.cms.data.type.ModelItemTypeConstants; 042import org.ametys.cms.repository.Content; 043import org.ametys.cms.search.content.ContentValuesExtractorFactory; 044import org.ametys.cms.search.content.ContentValuesExtractorFactory.SearchModelContentValuesExtractor; 045import org.ametys.cms.search.model.SearchModelHelper; 046import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 047import org.ametys.cms.search.ui.model.SearchUIModel; 048import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint; 049import org.ametys.cms.search.ui.model.StaticSearchUIModel; 050import org.ametys.plugins.core.ui.script.ScriptExecArguments; 051import org.ametys.plugins.core.ui.script.ScriptHandler; 052import org.ametys.plugins.repository.AmetysObjectIterable; 053import org.ametys.runtime.model.ModelItem; 054import org.ametys.runtime.model.ViewItemContainer; 055import org.ametys.runtime.model.ViewParser; 056import org.ametys.runtime.model.type.ModelItemType; 057import org.ametys.runtime.plugin.component.ThreadSafeComponentManager; 058 059/** 060 * Content aware script handler using search model 061 */ 062public class CmsScriptHandler extends ScriptHandler 063{ 064 private SearchModelHelper _searchUIModelHelper; 065 private SearchUIModelExtensionPoint _searchUIModelEP; 066 private ContentTypesHelper _contentTypesHelper; 067 private ContentTypeExtensionPoint _contentTypeExtensionPoint; 068 private ServiceManager _manager; 069 private ContentValuesExtractorFactory _valuesExtractorFactory; 070 private ContentHelper _contentHelper; 071 private SystemPropertyExtensionPoint _systemPropertyExtensionPoint; 072 073 @Override 074 public void service(ServiceManager serviceManager) throws ServiceException 075 { 076 _manager = serviceManager; 077 super.service(serviceManager); 078 _searchUIModelEP = (SearchUIModelExtensionPoint) serviceManager.lookup(SearchUIModelExtensionPoint.ROLE); 079 _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE); 080 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 081 _searchUIModelHelper = (SearchModelHelper) serviceManager.lookup(SearchModelHelper.ROLE); 082 _valuesExtractorFactory = (ContentValuesExtractorFactory) serviceManager.lookup(ContentValuesExtractorFactory.ROLE); 083 _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE); 084 _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) serviceManager.lookup(SystemPropertyExtensionPoint.ROLE); 085 } 086 087 @Override 088 protected ScriptExecArguments buildExecArguments(Map<String, Object> arguments) 089 { 090 return new CmsScriptExecArguments.Impl(arguments); 091 } 092 093 @SuppressWarnings("unchecked") 094 @Override 095 protected Object processScriptResult(Map<String, Object> results, Object scriptResult, ScriptExecArguments execArgs) 096 { 097 Object processedScriptResult = super.processScriptResult(results, scriptResult, execArgs); 098 099 Collection<String> columns = null; 100 String defaultModelId = Optional.of(execArgs) 101 .filter(CmsScriptExecArguments.class::isInstance) 102 .map(CmsScriptExecArguments.class::cast) 103 .flatMap(CmsScriptExecArguments::searchModelId) 104 .orElse(null); 105 List<Content> contents = null; 106 SearchUIModel model = null; 107 108 Object processedResults = Optional.ofNullable(results.get("results")).orElse(results.get("contents")); 109 110 Object columnsObject = results.get("columns"); 111 if (columnsObject instanceof Collection) 112 { 113 columns = (Collection<String>) columnsObject; 114 } 115 116 if (processedResults instanceof Collection) 117 { 118 Collection< ? > proccessedResultsCollection = (Collection<?>) processedResults; 119 contents = proccessedResultsCollection.stream() 120 .filter(Content.class::isInstance) 121 .map(Content.class::cast) 122 .collect(Collectors.toList()); 123 } 124 125 try 126 { 127 model = getOrCreateModel(columns, contents, defaultModelId); 128 if (columns != null) 129 { 130 Map<String, Object> modelConfiguration = _searchUIModelHelper.getSearchModelInfo(model, Collections.EMPTY_MAP); 131 results.put("columns", modelConfiguration.get("columns")); 132 } 133 134 if (model != null && contents != null && !contents.isEmpty()) 135 { 136 ViewItemContainer resultItems = model.getResultItems(Collections.EMPTY_MAP); 137 SearchModelContentValuesExtractor extractor = _valuesExtractorFactory.create(model); 138 List<Map<String, Object>> contentsJson = contents.stream() 139 .map(content -> content2Json(content, resultItems, extractor)) 140 .collect(Collectors.toList()); 141 results.put("contents", contentsJson); 142 results.put("size", contents.size()); 143 } 144 } 145 catch (Exception e) 146 { 147 // Clear results to avoid sending Non JSON object to JSONReader 148 results.remove("results"); 149 results.remove("contents"); 150 results.remove("columns"); 151 throw new RuntimeException(e); 152 } 153 154 return processedScriptResult; 155 } 156 157 @Override 158 protected ResultProcessor getProcessor() 159 { 160 return new CmsResultProcessor(); 161 } 162 163 /** 164 * Computes the model associated with the given contents and result columns.<br> 165 * Used by scripts functions creating reports or search results. 166 * @param columns the columns of the results 167 * @param contents the contents for the results (one content per line) 168 * @param defaultModelId the fallback model 169 * @return the computed search model 170 * @throws Exception if something went wrong. 171 */ 172 public SearchUIModel getOrCreateModel(Collection<String> columns, List<Content> contents, String defaultModelId) throws Exception 173 { 174 if (columns != null 175 || contents != null && !contents.isEmpty()) 176 { 177 ThreadSafeComponentManager<SearchUIModel> localSearchModelManager = null; 178 // Handling model 179 try 180 { 181 localSearchModelManager = new ThreadSafeComponentManager<>(); 182 localSearchModelManager.setLogger(getLogger()); 183 localSearchModelManager.contextualize(_context); 184 localSearchModelManager.service(_manager); 185 186 return _createModel(localSearchModelManager, defaultModelId, columns, contents); 187 } 188 catch (Exception e) 189 { 190 getLogger().error("Error while retrieving the search model :" + e.getMessage(), e); 191 throw new Exception("Error while retrieving the search model : " + e.getMessage(), e); 192 } 193 finally 194 { 195 if (localSearchModelManager != null) 196 { 197 localSearchModelManager.dispose(); 198 } 199 } 200 } 201 else 202 { 203 return defaultModelId != null ? _searchUIModelEP.getExtension(defaultModelId) : null; 204 } 205 } 206 207 208 /** 209 * Create and return a dynamic model based on desired columns or return a default model. 210 * @param localSearchModelManager The local search manager 211 * @param defaultModelId The default model id 212 * @param columns The columns 213 * @param contents The contents 214 * @return The search model 215 * @throws Exception If an error occurred 216 */ 217 protected SearchUIModel _createModel(ThreadSafeComponentManager<SearchUIModel> localSearchModelManager, String defaultModelId, Collection<String> columns, Collection<Content> contents) throws Exception 218 { 219 /* 220 * Configuration will have the following structure : 221 * <SearchModel> 222 * <content-types> 223 * <content-type id="CTYPE_ID"/> 224 * <...> 225 * </content-types> 226 * <columns> 227 * <default> 228 * <item ref="COLUMN_ID|*" [specific attr might be needed depending on column]> 229 * [specific value or child elements might be needed depending on column] 230 * </item> 231 * <...> 232 * </default> 233 * </columns> 234 * </SearchModel> 235 */ 236 237 MutableConfiguration conf = new DefaultConfiguration((String) null); 238 MutableConfiguration modelConf = new DefaultConfiguration("SearchModel"); 239 conf.addChild(modelConf); 240 241 // content types 242 MutableConfiguration contentTypesConf = new DefaultConfiguration("content-types"); 243 modelConf.addChild(contentTypesConf); 244 245 Set<String> contentTypeIds = new HashSet<>(); 246 if (contents != null) 247 { 248 for (Content content : contents) 249 { 250 contentTypeIds.addAll(Arrays.asList(content.getTypes())); 251 } 252 } 253 254 Set<ContentType> contentTypes = contentTypeIds.stream() 255 .map(_contentTypeExtensionPoint::getExtension) 256 .collect(Collectors.toSet()); 257 for (ContentType contentType : contentTypes) 258 { 259 MutableConfiguration cTypeConf = new DefaultConfiguration("content-type"); 260 cTypeConf.setAttribute("id", contentType.getId()); 261 contentTypesConf.addChild(cTypeConf); 262 } 263 264 // columns 265 MutableConfiguration columnsConf = new DefaultConfiguration("columns"); 266 modelConf.addChild(columnsConf); 267 268 MutableConfiguration columnsDefaultConf = new DefaultConfiguration("default"); 269 columnsConf.addChild(columnsDefaultConf); 270 271 if (columns != null && !columns.isEmpty()) 272 { 273 // Add an item for each column 274 for (String column : columns) 275 { 276 String modelItemPath = StringUtils.replace(column, ".", ModelItem.ITEM_PATH_SEPARATOR); 277 MutableConfiguration columnConf = new DefaultConfiguration(ViewParser.ADD_ITEM_TAG_NAME); 278 columnConf.setAttribute(ViewParser.ITEM_REFERENCE_ATTRIBUTE_NAME, modelItemPath); 279 columnsDefaultConf.addChild(columnConf); 280 } 281 } 282 else if (_hasNonCommonAncestorWithTitle(contentTypeIds)) 283 { 284 // Add all attributes 285 MutableConfiguration columnConf = new DefaultConfiguration(ViewParser.ADD_ITEM_TAG_NAME); 286 columnConf.setAttribute(ViewParser.ITEM_REFERENCE_ATTRIBUTE_NAME, ViewParser.ALL_ITEMS_REFERENCE); 287 columnsDefaultConf.addChild(columnConf); 288 289 // Add all system properties 290 for (String systemPropertyId : _systemPropertyExtensionPoint.getExtensionsIds()) 291 { 292 if (_systemPropertyExtensionPoint.isDisplayable(systemPropertyId)) 293 { 294 columnConf = new DefaultConfiguration(ViewParser.ADD_ITEM_TAG_NAME); 295 columnConf.setAttribute(ViewParser.ITEM_REFERENCE_ATTRIBUTE_NAME, systemPropertyId); 296 columnsDefaultConf.addChild(columnConf); 297 } 298 } 299 } 300 301 if (columnsDefaultConf.getChildren().length == 0) 302 { 303 if (getLogger().isInfoEnabled()) 304 { 305 getLogger().info("No columns found. The default model will be used"); 306 } 307 308 return _searchUIModelEP.getExtension(defaultModelId); 309 } 310 else 311 { 312 localSearchModelManager.addComponent("script", null, "script-search-model", StaticSearchUIModel.class, conf); 313 localSearchModelManager.initialize(); 314 return localSearchModelManager.lookup("script-search-model"); 315 } 316 } 317 318 private boolean _hasNonCommonAncestorWithTitle(Set<String> contentTypeIds) 319 { 320 return _contentTypesHelper.getCommonAncestors(contentTypeIds) 321 .stream() 322 .map(_contentTypeExtensionPoint::getExtension) 323 .anyMatch(ancestor -> ancestor.hasModelItem(Content.ATTRIBUTE_TITLE)); 324 } 325 326 /** 327 * Convert content to json 328 * @param content The content 329 * @param resultItems The result items, to know which value to fill 330 * @param extractor The properties extractor 331 * @return The json data 332 */ 333 public Map<String, Object> content2Json(Content content, ViewItemContainer resultItems, SearchModelContentValuesExtractor extractor) 334 { 335 Map<String, Object> contentData = new HashMap<>(); 336 337 contentData.put("id", content.getId()); 338 contentData.put("name", content.getName()); 339 340 if (_contentHelper.isMultilingual(content) && _isTitleMultilingual(content)) 341 { 342 contentData.put(Content.ATTRIBUTE_TITLE, _contentHelper.getTitleVariants(content)); 343 } 344 else 345 { 346 contentData.put(Content.ATTRIBUTE_TITLE, _contentHelper.getTitle(content)); 347 } 348 349 contentData.put("language", content.getLanguage()); 350 contentData.put("contentTypes", content.getTypes()); 351 contentData.put("mixins", content.getMixinTypes()); 352 contentData.put("iconGlyph", _contentTypesHelper.getIconGlyph(content)); 353 contentData.put("iconDecorator", _contentTypesHelper.getIconDecorator(content)); 354 contentData.put("smallIcon", _contentTypesHelper.getSmallIcon(content)); 355 contentData.put("mediumIcon", _contentTypesHelper.getMediumIcon(content)); 356 contentData.put("largeIcon", _contentTypesHelper.getLargeIcon(content)); 357 contentData.put("isSimple", _contentHelper.isSimple(content)); 358 359 contentData.putAll(extractor.getValues(content, null, new HashMap<>())); 360 361 return contentData; 362 } 363 364 private boolean _isTitleMultilingual(Content content) 365 { 366 return Optional.ofNullable(content) 367 .map(c -> c.getDefinition(Content.ATTRIBUTE_TITLE)) 368 .map(ModelItem::getType) 369 .map(ModelItemType::getId) 370 .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals) 371 .orElse(false); 372 } 373 374 static class CmsResultProcessor extends ResultProcessor 375 { 376 @SuppressWarnings("unchecked") 377 @Override 378 protected Object process(Map<String, Object> results, Object scriptResult) 379 { 380 if (scriptResult instanceof Content) 381 { 382 List<Object> contents = (List<Object>) results.computeIfAbsent("contents", __ -> new ArrayList<>()); 383 contents.add(scriptResult); 384 385 return scriptResult.toString(); 386 } 387 else if (scriptResult instanceof Map) 388 { 389 Map<Object, Object> elements = new HashMap<>(); 390 Map scriptResultMap = (Map) scriptResult; 391 List<String> contents = _processScriptResultContents(results, scriptResultMap); 392 if (contents != null) 393 { 394 elements.put("results", contents); 395 } 396 397 if (scriptResultMap.containsKey("columns")) 398 { 399 results.put("columns", scriptResultMap.get("columns")); 400 } 401 402 // Map 403 for (Object key : scriptResultMap.keySet()) 404 { 405 if (!"results".equals(key)) 406 { 407 Object value = scriptResultMap.get(key); 408 elements.put(process(results, key), process(results, value)); 409 } 410 } 411 412 return elements; 413 } 414 415 return super.process(results, scriptResult); 416 } 417 418 @SuppressWarnings("unchecked") 419 private List<String> _processScriptResultContents(Map<String, Object> results, Map scriptResultMap) 420 { 421 if (scriptResultMap.containsKey("results")) 422 { 423 Object rawResults = scriptResultMap.get("results"); 424 Collection<Content> rawResultCollection = null; 425 if (rawResults instanceof AmetysObjectIterable) 426 { 427 try (AmetysObjectIterable<Content> rawResultIterable = (AmetysObjectIterable<Content>) rawResults) 428 { 429 rawResultCollection = rawResultIterable.stream() 430 .collect(Collectors.toList()); 431 } 432 } 433 else if (rawResults instanceof Collection) 434 { 435 rawResultCollection = new LinkedList<>((Collection<Content>) rawResults); 436 } 437 else if (rawResults instanceof Map) 438 { 439 rawResultCollection = ((Map) rawResults).values(); 440 } 441 442 if (rawResultCollection != null) 443 { 444 results.put("contents", rawResultCollection); 445 return rawResultCollection.stream() 446 .map(content -> content.toString()) 447 .collect(Collectors.toList()); 448 } 449 } 450 return null; 451 } 452 } 453}