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