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}