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