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.SearchUIColumn; 047import org.ametys.cms.search.ui.model.SearchUIModel; 048import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint; 049import org.ametys.cms.search.ui.model.SearchUIModelHelper; 050import org.ametys.cms.search.ui.model.StaticSearchUIModel; 051import org.ametys.plugins.core.ui.script.ScriptExecArguments; 052import org.ametys.plugins.core.ui.script.ScriptHandler; 053import org.ametys.plugins.repository.AmetysObjectIterable; 054import org.ametys.runtime.model.ModelHelper; 055import org.ametys.runtime.model.ModelItem; 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 SearchUIModelHelper _searchUIModelHelper; 065 private SearchUIModelExtensionPoint _searchUIModelEP; 066 private ContentTypesHelper _contentTypesHelper; 067 private ContentTypeExtensionPoint _contentTypeExtensionPoint; 068 private SystemPropertyExtensionPoint _systemPropEP; 069 private ServiceManager _manager; 070 private ContentValuesExtractorFactory _valuesExtractorFactory; 071 private ContentHelper _contentHelper; 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 _systemPropEP = (SystemPropertyExtensionPoint) serviceManager.lookup(SystemPropertyExtensionPoint.ROLE); 082 _searchUIModelHelper = (SearchUIModelHelper) serviceManager.lookup(SearchUIModelHelper.ROLE); 083 _valuesExtractorFactory = (ContentValuesExtractorFactory) serviceManager.lookup(ContentValuesExtractorFactory.ROLE); 084 _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.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 Collection<SearchUIColumn> values = model.getResultFields(Collections.EMPTY_MAP).values(); 137 SearchModelContentValuesExtractor extractor = _valuesExtractorFactory.create(model).setFullValues(true); 138 List<Map<String, Object>> contentsJson = contents.stream() 139 .map(content -> content2Json(content, values, extractor)) 140 .collect(Collectors.toList()); 141 results.put("contents", contentsJson); 142 results.put("size", contents.size()); 143 } 144 } 145 catch (Exception e) 146 { 147 throw new RuntimeException(e); 148 } 149 150 return processedScriptResult; 151 } 152 153 @Override 154 protected ResultProcessor getProcessor() 155 { 156 return new CmsResultProcessor(); 157 } 158 159 /** 160 * Computes the model associated with the given contents and result columns.<br> 161 * Used by scripts functions creating reports or search results. 162 * @param columns the columns of the results 163 * @param contents the contents for the results (one content per line) 164 * @param defaultModelId the fallback model 165 * @return the computed search model 166 * @throws Exception if something went wrong. 167 */ 168 public SearchUIModel getOrCreateModel(Collection<String> columns, List<Content> contents, String defaultModelId) throws Exception 169 { 170 if (columns != null 171 || contents != null && !contents.isEmpty()) 172 { 173 ThreadSafeComponentManager<SearchUIModel> localSearchModelManager = null; 174 // Handling model 175 try 176 { 177 localSearchModelManager = new ThreadSafeComponentManager<>(); 178 localSearchModelManager.setLogger(getLogger()); 179 localSearchModelManager.contextualize(_context); 180 localSearchModelManager.service(_manager); 181 182 return _createModel(localSearchModelManager, defaultModelId, columns, contents); 183 } 184 catch (Exception e) 185 { 186 getLogger().error("Error while retrieving the search model :" + e.getMessage(), e); 187 throw new Exception("Error while retrieving the search model : " + e.getMessage(), e); 188 } 189 finally 190 { 191 if (localSearchModelManager != null) 192 { 193 localSearchModelManager.dispose(); 194 } 195 } 196 } 197 else 198 { 199 return defaultModelId != null ? _searchUIModelEP.getExtension(defaultModelId) : null; 200 } 201 } 202 203 204 /** 205 * Create and return a dynamic model based on desired columns or return a default model. 206 * @param localSearchModelManager The local search manager 207 * @param defaultModelId The default model id 208 * @param columns The columns 209 * @param contents The contents 210 * @return The search model 211 * @throws Exception If an error occurred 212 */ 213 protected SearchUIModel _createModel(ThreadSafeComponentManager<SearchUIModel> localSearchModelManager, String defaultModelId, Collection<String> columns, Collection<Content> contents) throws Exception 214 { 215 /* 216 * Configuration will have the following structure : 217 * <SearchModel> 218 * <content-types> 219 * <content-type id="CTYPE_ID"/> 220 * <...> 221 * </content-types> 222 * <columns> 223 * <default> 224 * <column system-ref|metadata-ref="COLUMN_ID|*" [specific attr might be needed depending on column]> 225 * [specific value or child elements might be needed depending on column] 226 * </column> 227 * <...> 228 * </default> 229 * </columns> 230 * </SearchModel> 231 */ 232 233 MutableConfiguration conf = new DefaultConfiguration((String) null); 234 MutableConfiguration modelConf = new DefaultConfiguration("SearchModel"); 235 conf.addChild(modelConf); 236 237 // content types 238 MutableConfiguration contentTypesConf = new DefaultConfiguration("content-types"); 239 modelConf.addChild(contentTypesConf); 240 241 Set<ContentType> cTypeCommonAncestors = new HashSet<>(); 242 if (contents != null) 243 { 244 Set<String> contentTypeIds = new HashSet<>(); 245 for (Content content : contents) 246 { 247 contentTypeIds.addAll(Arrays.asList(content.getTypes())); 248 } 249 250 cTypeCommonAncestors = _contentTypesHelper.getCommonAncestors(contentTypeIds).stream() 251 .map(_contentTypeExtensionPoint::getExtension) 252 .collect(Collectors.toSet()); 253 } 254 255 for (ContentType ancestor : cTypeCommonAncestors) 256 { 257 MutableConfiguration cTypeConf = new DefaultConfiguration("content-type"); 258 cTypeConf.setAttribute("id", ancestor.getId()); 259 contentTypesConf.addChild(cTypeConf); 260 } 261 262 // columns 263 MutableConfiguration columnsConf = new DefaultConfiguration("columns"); 264 modelConf.addChild(columnsConf); 265 266 MutableConfiguration columnsDefaultConf = new DefaultConfiguration("default"); 267 columnsConf.addChild(columnsDefaultConf); 268 269 if (columns != null && !columns.isEmpty()) 270 { 271 _addColumnsConfiguration(columns, cTypeCommonAncestors, columnsDefaultConf); 272 } 273 else if (_hasNonAbstractAncestors(cTypeCommonAncestors)) 274 { 275 // metadata-ref="*" 276 MutableConfiguration columnConf = new DefaultConfiguration("column"); 277 columnConf.setAttribute("metadata-ref", "*"); 278 columnsDefaultConf.addChild(columnConf); 279 280 // system-ref="*" 281 columnConf = new DefaultConfiguration("column"); 282 columnConf.setAttribute("system-ref", "*"); 283 columnsDefaultConf.addChild(columnConf); 284 } 285 286 if (columnsDefaultConf.getChildren().length == 0) 287 { 288 if (getLogger().isInfoEnabled()) 289 { 290 getLogger().info("No columns found. The default model will be used"); 291 } 292 293 return _searchUIModelEP.getExtension(defaultModelId); 294 } 295 else 296 { 297 localSearchModelManager.addComponent("script", null, "script-search-model", StaticSearchUIModel.class, conf); 298 localSearchModelManager.initialize(); 299 return localSearchModelManager.lookup("script-search-model"); 300 } 301 } 302 303 private boolean _hasNonAbstractAncestors(Set<ContentType> commonAncestors) 304 { 305 for (ContentType ancestor : commonAncestors) 306 { 307 if (ancestor.getView("main") != null && ancestor.hasModelItem(Content.ATTRIBUTE_TITLE)) 308 { 309 return true; 310 } 311 } 312 313 // None of the content type has a main view and a title attribute 314 return false; 315 } 316 317 private void _addColumnsConfiguration(Collection<String> columns, Set<ContentType> commonAncestors, MutableConfiguration columnsDefaultConf) 318 { 319 for (String column : columns) 320 { 321 if (_systemPropEP.hasExtension(column)) 322 { 323 MutableConfiguration columnConf = new DefaultConfiguration("column"); 324 columnConf.setAttribute("system-ref", column); 325 columnsDefaultConf.addChild(columnConf); 326 } 327 else if (Content.ATTRIBUTE_TITLE.equals(column)) 328 { 329 MutableConfiguration columnConf = new DefaultConfiguration("column"); 330 columnConf.setAttribute("metadata-ref", column); 331 columnsDefaultConf.addChild(columnConf); 332 } 333 else if (!commonAncestors.isEmpty()) 334 { 335 String attributePath = StringUtils.replace(column, ".", "/"); 336 if (ModelHelper.hasModelItem(attributePath, commonAncestors)) 337 { 338 MutableConfiguration columnConf = new DefaultConfiguration("column"); 339 columnConf.setAttribute("metadata-ref", column); 340 _handleColumnConfiguration(columnConf, column); 341 columnsDefaultConf.addChild(columnConf); 342 } 343 else 344 { 345 if (getLogger().isInfoEnabled()) 346 { 347 Set<String> commonAncestorIds = commonAncestors.stream() 348 .map(ContentType::getId) 349 .collect(Collectors.toSet()); 350 getLogger().info("Unknown metadata '" + attributePath + "' in content types '" + StringUtils.join(commonAncestorIds, ", ") + "'"); 351 } 352 } 353 } 354 } 355 } 356 357 /** 358 * Add/modify column configuration 359 * @param columnConf The mutable configuration object that will be used to create the column. 360 * @param column The column identifier 361 */ 362 protected void _handleColumnConfiguration(MutableConfiguration columnConf, String column) 363 { 364 // Title 365 // Add specific renderer 366 if ("title".equals(column)) 367 { 368 MutableConfiguration rendererConf = new DefaultConfiguration("renderer"); 369 rendererConf.setValue("Ametys.cms.content.EditContentsGrid.renderTitle"); 370 371 columnConf.addChild(rendererConf); 372 } 373 } 374 375 /** 376 * Convert content to json 377 * @param content The content 378 * @param searchColumns The columns, to know which value to fill 379 * @param extractor The properties extractor 380 * @return The json data 381 */ 382 public Map<String, Object> content2Json(Content content, Collection<SearchUIColumn> searchColumns, SearchModelContentValuesExtractor extractor) 383 { 384 Map<String, Object> contentData = new HashMap<>(); 385 386 contentData.put("id", content.getId()); 387 contentData.put("name", content.getName()); 388 389 if (_contentHelper.isMultilingual(content) && _isTitleMultilingual(content)) 390 { 391 contentData.put(Content.ATTRIBUTE_TITLE, _contentHelper.getTitleVariants(content)); 392 } 393 else 394 { 395 contentData.put(Content.ATTRIBUTE_TITLE, _contentHelper.getTitle(content)); 396 } 397 398 contentData.put("language", content.getLanguage()); 399 contentData.put("contentTypes", content.getTypes()); 400 contentData.put("mixins", content.getMixinTypes()); 401 contentData.put("iconGlyph", _contentTypesHelper.getIconGlyph(content)); 402 contentData.put("iconDecorator", _contentTypesHelper.getIconDecorator(content)); 403 contentData.put("smallIcon", _contentTypesHelper.getSmallIcon(content)); 404 contentData.put("mediumIcon", _contentTypesHelper.getMediumIcon(content)); 405 contentData.put("largeIcon", _contentTypesHelper.getLargeIcon(content)); 406 contentData.put("isSimple", _contentHelper.isSimple(content)); 407 408 contentData.putAll(extractor.getValues(content, null)); 409 410 return contentData; 411 } 412 413 private boolean _isTitleMultilingual(Content content) 414 { 415 return Optional.ofNullable(content) 416 .map(c -> c.getDefinition(Content.ATTRIBUTE_TITLE)) 417 .map(ModelItem::getType) 418 .map(ModelItemType::getId) 419 .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals) 420 .orElse(false); 421 } 422 423 static class CmsResultProcessor extends ResultProcessor 424 { 425 @SuppressWarnings("unchecked") 426 @Override 427 protected Object process(Map<String, Object> results, Object scriptResult) 428 { 429 if (scriptResult instanceof Content) 430 { 431 List<Object> contents = (List<Object>) results.computeIfAbsent("contents", __ -> new ArrayList<>()); 432 contents.add(scriptResult); 433 434 return ((Content) scriptResult).toString(); 435 } 436 else if (scriptResult instanceof Map) 437 { 438 Map<Object, Object> elements = new HashMap<>(); 439 Map scriptResultMap = (Map) scriptResult; 440 List<String> contents = _processScriptResultContents(results, scriptResultMap); 441 if (contents != null) 442 { 443 elements.put("results", contents); 444 } 445 446 if (scriptResultMap.containsKey("columns")) 447 { 448 results.put("columns", scriptResultMap.get("columns")); 449 } 450 451 // Map 452 for (Object key : scriptResultMap.keySet()) 453 { 454 if (!"results".equals(key)) 455 { 456 Object value = scriptResultMap.get(key); 457 elements.put(process(results, key), process(results, value)); 458 } 459 } 460 461 return elements; 462 } 463 464 return super.process(results, scriptResult); 465 } 466 467 @SuppressWarnings("unchecked") 468 private List<String> _processScriptResultContents(Map<String, Object> results, Map scriptResultMap) 469 { 470 if (scriptResultMap.containsKey("results")) 471 { 472 Object rawResults = scriptResultMap.get("results"); 473 Collection<Content> rawResultCollection = null; 474 if (rawResults instanceof AmetysObjectIterable) 475 { 476 try (AmetysObjectIterable<Content> rawResultIterable = (AmetysObjectIterable<Content>) rawResults) 477 { 478 rawResultCollection = rawResultIterable.stream() 479 .collect(Collectors.toList()); 480 } 481 } 482 else if (rawResults instanceof Collection) 483 { 484 rawResultCollection = new LinkedList<>((Collection<Content>) rawResults); 485 } 486 else if (rawResults instanceof Map) 487 { 488 rawResultCollection = ((Map) rawResults).values(); 489 } 490 491 if (rawResultCollection != null) 492 { 493 results.put("contents", rawResultCollection); 494 return rawResultCollection.stream() 495 .map(content -> content.toString()) 496 .collect(Collectors.toList()); 497 } 498 } 499 return null; 500 } 501 } 502}