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