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