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.Iterator; 025import java.util.LinkedList; 026import java.util.List; 027import java.util.Map; 028import java.util.Optional; 029import java.util.Set; 030import java.util.stream.Collectors; 031 032import javax.script.ScriptException; 033 034import org.apache.avalon.framework.configuration.DefaultConfiguration; 035import org.apache.avalon.framework.configuration.MutableConfiguration; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.cocoon.ProcessingException; 039import org.apache.commons.lang3.StringUtils; 040 041import org.ametys.cms.content.ContentHelper; 042import org.ametys.cms.contenttype.ContentType; 043import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 044import org.ametys.cms.contenttype.ContentTypesHelper; 045import org.ametys.cms.contenttype.MetadataDefinition; 046import org.ametys.cms.contenttype.MetadataType; 047import org.ametys.cms.repository.Content; 048import org.ametys.cms.search.content.ContentValuesExtractorFactory; 049import org.ametys.cms.search.content.ContentValuesExtractorFactory.SearchModelContentValuesExtractor; 050import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 051import org.ametys.cms.search.ui.model.SearchUIColumn; 052import org.ametys.cms.search.ui.model.SearchUIModel; 053import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint; 054import org.ametys.cms.search.ui.model.SearchUIModelHelper; 055import org.ametys.cms.search.ui.model.StaticSearchUIModel; 056import org.ametys.core.util.ServerCommHelper; 057import org.ametys.plugins.core.ui.script.ScriptBinding; 058import org.ametys.plugins.core.ui.script.ScriptHandler; 059import org.ametys.plugins.repository.AmetysObjectIterable; 060import org.ametys.runtime.plugin.component.ThreadSafeComponentManager; 061 062import com.google.common.collect.ImmutableSet; 063 064/** 065 * Content aware script handler using search model 066 */ 067public class CmsScriptHandler extends ScriptHandler 068{ 069 private SearchUIModelHelper _searchUIModelHelper; 070 private SearchUIModelExtensionPoint _searchUIModelEP; 071 private ContentTypesHelper _contentTypesHelper; 072 private ContentTypeExtensionPoint _contentTypeExtensionPoint; 073 private SystemPropertyExtensionPoint _systemPropEP; 074 private ServerCommHelper _serverCommHelper; 075 private ServiceManager _manager; 076 private ContentValuesExtractorFactory _valuesExtractorFactory; 077 private ContentHelper _contentHelper; 078 079 @Override 080 public void service(ServiceManager serviceManager) throws ServiceException 081 { 082 _manager = serviceManager; 083 super.service(serviceManager); 084 _searchUIModelEP = (SearchUIModelExtensionPoint) serviceManager.lookup(SearchUIModelExtensionPoint.ROLE); 085 _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE); 086 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 087 _systemPropEP = (SystemPropertyExtensionPoint) serviceManager.lookup(SystemPropertyExtensionPoint.ROLE); 088 _searchUIModelHelper = (SearchUIModelHelper) serviceManager.lookup(SearchUIModelHelper.ROLE); 089 _serverCommHelper = (ServerCommHelper) serviceManager.lookup(ServerCommHelper.ROLE); 090 _valuesExtractorFactory = (ContentValuesExtractorFactory) serviceManager.lookup(ContentValuesExtractorFactory.ROLE); 091 _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE); 092 } 093 094 @Override 095 protected Object processScriptResult(Map<String, Object> results, List<ScriptBinding> scriptBindings, Object scriptResult) throws ScriptException 096 { 097 Object processedScriptResult = _processScriptResult(results, scriptBindings, scriptResult); 098 099 Map columns = null; 100 String defaultModelId = Optional.ofNullable(_serverCommHelper.getJsParameters()) 101 .map(params -> params.get("parameters")) 102 .map(List.class::cast) 103 .map(list -> list.get(0)) 104 .map(Map.class::cast) 105 .map(params -> (String) params.get("model")) 106 .orElse(null); 107 List<Content> contents = null; 108 SearchUIModel model = null; 109 110 Object processedResults = Optional.ofNullable(results.get("results")).orElse(results.get("contents")); 111 112 Object columnsObject = results.get("columns"); 113 if (columnsObject != null && columnsObject instanceof Map) 114 { 115 columns = (Map) columnsObject; 116 } 117 118 if (processedResults != null && processedResults instanceof Collection) 119 { 120 121 Collection< ? > proccessedResultsCollection = (Collection<?>) processedResults; 122 contents = proccessedResultsCollection.stream().filter(Content.class::isInstance).map(Content.class::cast).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 ScriptException(e); 148 } 149 150 return processedScriptResult; 151 } 152 153 private SearchUIModel _getOrCreateModel(Map columns, List<Content> contents, String defaultModelId) throws Exception, ProcessingException 154 { 155 if (columns != null || (contents != null && !contents.isEmpty())) 156 { 157 ThreadSafeComponentManager<SearchUIModel> localSearchModelManager = null; 158 // Handling model 159 try 160 { 161 localSearchModelManager = new ThreadSafeComponentManager<>(); 162 localSearchModelManager.setLogger(getLogger()); 163 localSearchModelManager.contextualize(_context); 164 localSearchModelManager.service(_manager); 165 166 return _createModel(localSearchModelManager, defaultModelId, columns != null ? columns.values() : null, contents); 167 } 168 catch (Exception e) 169 { 170 getLogger().error("Error while retrieving the search model :" + e.getMessage(), e); 171 throw new Exception("Error while retrieving the search model : " + e.getMessage(), e); 172 } 173 finally 174 { 175 if (localSearchModelManager != null) 176 { 177 localSearchModelManager.dispose(); 178 } 179 } 180 } 181 else 182 { 183 return defaultModelId != null ? _searchUIModelEP.getExtension(defaultModelId) : null; 184 } 185 } 186 187 188 /** 189 * Create and return a dynamic model based on desired columns or return a default model. 190 * @param localSearchModelManager The local search manager 191 * @param defaultModelId The default model id 192 * @param columns The columns 193 * @param contents The contents 194 * @return The search model 195 * @throws Exception If an error occurred 196 */ 197 protected SearchUIModel _createModel(ThreadSafeComponentManager<SearchUIModel> localSearchModelManager, String defaultModelId, Collection<String> columns, Collection<Content> contents) throws Exception 198 { 199 /* 200 * Configuration will have the following structure : 201 * <SearchModel> 202 * <content-types> 203 * <content-type id="CTYPE_ID"/> 204 * <...> 205 * </content-types> 206 * <columns> 207 * <default> 208 * <column system-ref|metadata-ref="COLUMN_ID|*" [specific attr might be needed depending on column]> 209 * [specific value or child elements might be needed depending on column] 210 * </column> 211 * <...> 212 * </default> 213 * </columns> 214 * </SearchModel> 215 */ 216 217 MutableConfiguration conf = new DefaultConfiguration((String) null); 218 MutableConfiguration modelConf = new DefaultConfiguration("SearchModel"); 219 conf.addChild(modelConf); 220 221 // content types 222 MutableConfiguration contentTypesConf = new DefaultConfiguration("content-types"); 223 modelConf.addChild(contentTypesConf); 224 225 ContentType cTypeCommonAncestor = null; 226 String cTypeCommonAncestorId = null; 227 228 if (contents != null) 229 { 230 Set<String> contentTypeIds = new HashSet<>(); 231 for (Content content : contents) 232 { 233 contentTypeIds.addAll(Arrays.asList(content.getTypes())); 234 } 235 236 cTypeCommonAncestorId = _contentTypesHelper.getCommonAncestor(contentTypeIds); 237 if (StringUtils.isNotEmpty(cTypeCommonAncestorId)) 238 { 239 cTypeCommonAncestor = _contentTypeExtensionPoint.getExtension(cTypeCommonAncestorId); 240 } 241 } 242 243 if (cTypeCommonAncestor != null) 244 { 245 MutableConfiguration cTypeConf = new DefaultConfiguration("content-type"); 246 cTypeConf.setAttribute("id", cTypeCommonAncestorId); 247 contentTypesConf.addChild(cTypeConf); 248 } 249 250 // columns 251 MutableConfiguration columnsConf = new DefaultConfiguration("columns"); 252 modelConf.addChild(columnsConf); 253 254 MutableConfiguration columnsDefaultConf = new DefaultConfiguration("default"); 255 columnsConf.addChild(columnsDefaultConf); 256 257 if (columns != null && !columns.isEmpty()) 258 { 259 _addColumnsConfiguration(columns, cTypeCommonAncestor, columnsDefaultConf); 260 } 261 else if (cTypeCommonAncestor != null && cTypeCommonAncestor.getMetadataSetForView("main") != null && cTypeCommonAncestor.hasMetadataDefinition("title")) 262 { 263 // metadata-ref="*" 264 MutableConfiguration columnConf = new DefaultConfiguration("column"); 265 columnConf.setAttribute("metadata-ref", "*"); 266 columnsDefaultConf.addChild(columnConf); 267 268 // system-ref="*" 269 columnConf = new DefaultConfiguration("column"); 270 columnConf.setAttribute("system-ref", "*"); 271 columnsDefaultConf.addChild(columnConf); 272 } 273 274 if (columnsDefaultConf.getChildren().length == 0) 275 { 276 if (getLogger().isInfoEnabled()) 277 { 278 getLogger().info("No columns found. The default model will be used"); 279 } 280 281 return _searchUIModelEP.getExtension(defaultModelId); 282 } 283 else 284 { 285 localSearchModelManager.addComponent("script", null, "script-search-model", StaticSearchUIModel.class, conf); 286 localSearchModelManager.initialize(); 287 return localSearchModelManager.lookup("script-search-model"); 288 } 289 } 290 291 292 @SuppressWarnings("unchecked") 293 private Object _processScriptResult(Map<String, Object> results, List<ScriptBinding> scriptBindings, Object scriptResult) throws ScriptException 294 { 295 if (scriptResult instanceof Content) 296 { 297 if (!results.containsKey("contents")) 298 { 299 results.put("contents", new ArrayList<>()); 300 } 301 ((List<Object>) results.get("contents")).add(scriptResult); 302 303 return ((Content) scriptResult).toString(); 304 } 305 else if (scriptResult instanceof Map) 306 { 307 Map<Object, Object> elements = new HashMap<>(); 308 Map scriptResultMap = (Map) scriptResult; 309 List<String> contents = _processScriptResultContents(results, scriptResultMap); 310 if (contents != null) 311 { 312 elements.put("results", contents); 313 } 314 315 if (scriptResultMap.containsKey("columns")) 316 { 317 results.put("columns", scriptResultMap.get("columns")); 318 } 319 320 // Map 321 for (Object key : scriptResultMap.keySet()) 322 { 323 if (!"results".equals(key)) 324 { 325 Object value = scriptResultMap.get(key); 326 elements.put(_processScriptResult(results, scriptBindings, key), _processScriptResult(results, scriptBindings, value)); 327 } 328 } 329 return elements; 330 } 331 else if (scriptResult instanceof Iterator) 332 { 333 List<Object> objs = new ArrayList<>(); 334 Iterator it = (Iterator) scriptResult; 335 while (it.hasNext()) 336 { 337 objs.add(_processScriptResult(results, scriptBindings, it.next())); 338 } 339 return objs; 340 } 341 else if (scriptResult instanceof Collection) 342 { 343 List<Object> objs = new ArrayList<>(); 344 for (Object obj : (Collection<Object>) scriptResult) 345 { 346 objs.add(_processScriptResult(results, scriptBindings, obj)); 347 } 348 return objs; 349 } 350 else 351 { 352 return scriptResult; 353 } 354 } 355 356 @SuppressWarnings("unchecked") 357 private List<String> _processScriptResultContents(Map<String, Object> results, Map scriptResultMap) 358 { 359 if (scriptResultMap.containsKey("results")) 360 { 361 Object rawResults = scriptResultMap.get("results"); 362 Collection<Content> rawResultCollection = null; 363 if (rawResults instanceof AmetysObjectIterable) 364 { 365 try (AmetysObjectIterable<Content> rawResultIterable = (AmetysObjectIterable<Content>) rawResults) 366 { 367 rawResultCollection = rawResultIterable.stream().collect(Collectors.toList()); 368 } 369 } 370 else if (rawResults instanceof Collection) 371 { 372 rawResultCollection = new LinkedList<>((Collection<Content>) rawResults); 373 } 374 else if (rawResults instanceof Map) 375 { 376 rawResultCollection = ((Map) rawResults).values(); 377 } 378 379 if (rawResultCollection != null) 380 { 381 results.put("contents", rawResultCollection); 382 return rawResultCollection.stream().map(content -> content.toString()).collect(Collectors.toList()); 383 } 384 } 385 return null; 386 } 387 388 389 private void _addColumnsConfiguration(Collection<String> columns, ContentType cTypeCommonAncestor, MutableConfiguration columnsDefaultConf) 390 { 391 for (String column : columns) 392 { 393 if (_systemPropEP.hasExtension(column)) 394 { 395 MutableConfiguration columnConf = new DefaultConfiguration("column"); 396 columnConf.setAttribute("system-ref", column); 397 columnsDefaultConf.addChild(columnConf); 398 } 399 else if ("title".equals(column)) 400 { 401 MutableConfiguration columnConf = new DefaultConfiguration("column"); 402 columnConf.setAttribute("metadata-ref", column); 403 columnsDefaultConf.addChild(columnConf); 404 } 405 else if (cTypeCommonAncestor != null) 406 { 407 String fullMetadataPath = StringUtils.replace(column, ".", "/"); 408 Map<String, MetadataDefinition> metadataDefinitions = _contentTypesHelper.getMetadataDefinitions(ImmutableSet.of(fullMetadataPath), cTypeCommonAncestor); 409 410 if (metadataDefinitions.getOrDefault(fullMetadataPath, null) == null) 411 { 412 if (getLogger().isInfoEnabled()) 413 { 414 getLogger().info("Unknown metadata '" + fullMetadataPath + "' in content type '" + cTypeCommonAncestor.getId() + "'"); 415 } 416 } 417 else 418 { 419 MutableConfiguration columnConf = new DefaultConfiguration("column"); 420 columnConf.setAttribute("metadata-ref", column); 421 _handleColumnConfiguration(columnConf, column); 422 columnsDefaultConf.addChild(columnConf); 423 } 424 } 425 } 426 } 427 428 /** 429 * Add/modify column configuration 430 * @param columnConf The mutable configuration object that will be used to create the column. 431 * @param column The column identifier 432 */ 433 protected void _handleColumnConfiguration(MutableConfiguration columnConf, String column) 434 { 435 // Title 436 // Add specific renderer 437 if ("title".equals(column)) 438 { 439 MutableConfiguration rendererConf = new DefaultConfiguration("renderer"); 440 rendererConf.setValue("Ametys.cms.content.EditContentsGrid.renderTitle"); 441 442 columnConf.addChild(rendererConf); 443 } 444 } 445 446 /** 447 * Convert content to json 448 * @param content The content 449 * @param searchColumns The columns, to know which value to fill 450 * @param extractor The properties extractor 451 * @return The json data 452 */ 453 protected Map<String, Object> content2Json(Content content, Collection<SearchUIColumn> searchColumns, SearchModelContentValuesExtractor extractor) 454 { 455 Map<String, Object> contentData = new HashMap<>(); 456 457 contentData.put("id", content.getId()); 458 contentData.put("name", content.getName()); 459 460 if (_contentHelper.isMultilingual(content) && _contentTypesHelper.getMetadataDefinition("title", content).getType() == MetadataType.MULTILINGUAL_STRING) 461 { 462 contentData.put("title", _contentHelper.getTitleVariants(content)); 463 } 464 else 465 { 466 contentData.put("title", _contentHelper.getTitle(content)); 467 } 468 469 contentData.put("language", content.getLanguage()); 470 contentData.put("contentTypes", content.getTypes()); 471 contentData.put("mixins", content.getMixinTypes()); 472 contentData.put("iconGlyph", _contentTypesHelper.getIconGlyph(content)); 473 contentData.put("iconDecorator", _contentTypesHelper.getIconDecorator(content)); 474 contentData.put("smallIcon", _contentTypesHelper.getSmallIcon(content)); 475 contentData.put("mediumIcon", _contentTypesHelper.getMediumIcon(content)); 476 contentData.put("largeIcon", _contentTypesHelper.getLargeIcon(content)); 477 contentData.put("isSimple", _contentHelper.isSimple(content)); 478 479 contentData.putAll(extractor.getValues(content, null)); 480 481 return contentData; 482 } 483}