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}