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.Iterator;
025import java.util.LinkedList;
026import java.util.List;
027import java.util.Map;
028import java.util.Optional;
029import java.util.Set;
030import java.util.stream.Collectors;
031
032import javax.script.ScriptException;
033
034import org.apache.avalon.framework.configuration.DefaultConfiguration;
035import org.apache.avalon.framework.configuration.MutableConfiguration;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.cocoon.ProcessingException;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.cms.content.ContentHelper;
042import org.ametys.cms.contenttype.ContentType;
043import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
044import org.ametys.cms.contenttype.ContentTypesHelper;
045import org.ametys.cms.contenttype.MetadataDefinition;
046import org.ametys.cms.contenttype.MetadataType;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.search.content.ContentValuesExtractorFactory;
049import org.ametys.cms.search.content.ContentValuesExtractorFactory.SearchModelContentValuesExtractor;
050import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
051import org.ametys.cms.search.ui.model.SearchUIColumn;
052import org.ametys.cms.search.ui.model.SearchUIModel;
053import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint;
054import org.ametys.cms.search.ui.model.SearchUIModelHelper;
055import org.ametys.cms.search.ui.model.StaticSearchUIModel;
056import org.ametys.core.util.ServerCommHelper;
057import org.ametys.plugins.core.ui.script.ScriptBinding;
058import org.ametys.plugins.core.ui.script.ScriptHandler;
059import org.ametys.plugins.repository.AmetysObjectIterable;
060import org.ametys.runtime.plugin.component.ThreadSafeComponentManager;
061
062import com.google.common.collect.ImmutableSet;
063
064/**
065 * Content aware script handler using search model 
066 */
067public class CmsScriptHandler extends ScriptHandler
068{
069    private SearchUIModelHelper _searchUIModelHelper;
070    private SearchUIModelExtensionPoint _searchUIModelEP;
071    private ContentTypesHelper _contentTypesHelper;
072    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
073    private SystemPropertyExtensionPoint _systemPropEP;
074    private ServerCommHelper _serverCommHelper;
075    private ServiceManager _manager;
076    private ContentValuesExtractorFactory _valuesExtractorFactory;
077    private ContentHelper _contentHelper;
078
079    @Override
080    public void service(ServiceManager serviceManager) throws ServiceException
081    {
082        _manager = serviceManager;
083        super.service(serviceManager);
084        _searchUIModelEP = (SearchUIModelExtensionPoint) serviceManager.lookup(SearchUIModelExtensionPoint.ROLE);
085        _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
086        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
087        _systemPropEP = (SystemPropertyExtensionPoint) serviceManager.lookup(SystemPropertyExtensionPoint.ROLE);
088        _searchUIModelHelper = (SearchUIModelHelper) serviceManager.lookup(SearchUIModelHelper.ROLE);
089        _serverCommHelper = (ServerCommHelper) serviceManager.lookup(ServerCommHelper.ROLE);
090        _valuesExtractorFactory = (ContentValuesExtractorFactory) serviceManager.lookup(ContentValuesExtractorFactory.ROLE);
091        _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE);
092    }
093    
094    @Override
095    protected Object processScriptResult(Map<String, Object> results, List<ScriptBinding> scriptBindings, Object scriptResult) throws ScriptException
096    {
097        Object processedScriptResult = _processScriptResult(results, scriptBindings, scriptResult);
098        
099        Map columns = null;
100        String defaultModelId = Optional.ofNullable(_serverCommHelper.getJsParameters())
101                                        .map(params -> params.get("parameters"))
102                                        .map(List.class::cast)
103                                        .map(list -> list.get(0))
104                                        .map(Map.class::cast)
105                                        .map(params -> (String) params.get("model"))
106                                        .orElse(null);
107        List<Content> contents = null;
108        SearchUIModel model = null;
109
110        Object processedResults = Optional.ofNullable(results.get("results")).orElse(results.get("contents"));
111        
112        Object columnsObject = results.get("columns");
113        if (columnsObject != null && columnsObject instanceof Map)
114        {
115            columns = (Map) columnsObject;
116        }
117        
118        if (processedResults != null && processedResults instanceof Collection)
119        {
120            
121            Collection< ? > proccessedResultsCollection = (Collection<?>) processedResults;
122            contents = proccessedResultsCollection.stream().filter(Content.class::isInstance).map(Content.class::cast).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 ScriptException(e);
148        }
149        
150        return processedScriptResult;
151    }
152
153    private SearchUIModel _getOrCreateModel(Map columns, List<Content> contents, String defaultModelId) throws Exception, ProcessingException
154    {
155        if (columns != null || (contents != null && !contents.isEmpty()))
156        {
157            ThreadSafeComponentManager<SearchUIModel> localSearchModelManager = null;
158            // Handling model
159            try
160            {
161                localSearchModelManager = new ThreadSafeComponentManager<>();
162                localSearchModelManager.setLogger(getLogger());
163                localSearchModelManager.contextualize(_context);
164                localSearchModelManager.service(_manager);
165                
166                return _createModel(localSearchModelManager, defaultModelId, columns != null ? columns.values() : null, contents);
167            }
168            catch (Exception e)
169            {
170                getLogger().error("Error while retrieving the search model :" + e.getMessage(), e);
171                throw new Exception("Error while retrieving the search model : " + e.getMessage(), e);
172            }
173            finally
174            {
175                if (localSearchModelManager != null)
176                {
177                    localSearchModelManager.dispose();
178                }
179            }
180        }
181        else
182        {
183            return defaultModelId != null ? _searchUIModelEP.getExtension(defaultModelId) : null;
184        }
185    }
186    
187
188    /**
189     * Create and return a dynamic model based on desired columns or return a default model.
190     * @param localSearchModelManager The local search manager
191     * @param defaultModelId The default model id
192     * @param columns The columns
193     * @param contents The contents
194     * @return The search model
195     * @throws Exception If an error occurred 
196     */
197    protected SearchUIModel _createModel(ThreadSafeComponentManager<SearchUIModel> localSearchModelManager, String defaultModelId, Collection<String> columns, Collection<Content> contents) throws Exception
198    {
199        /*
200         * Configuration will have the following structure :
201         *  <SearchModel>
202         *      <content-types>
203         *          <content-type id="CTYPE_ID"/>
204         *          <...>
205         *      </content-types>
206         *      <columns>
207         *          <default>
208         *              <column system-ref|metadata-ref="COLUMN_ID|*" [specific attr might be needed depending on column]>
209         *                  [specific value or child elements might be needed depending on column]
210         *              </column>
211         *              <...>
212         *          </default>
213         *      </columns>
214         *  </SearchModel>
215         */
216        
217        MutableConfiguration conf = new DefaultConfiguration((String) null);
218        MutableConfiguration modelConf = new DefaultConfiguration("SearchModel");
219        conf.addChild(modelConf);
220        
221        // content types
222        MutableConfiguration contentTypesConf = new DefaultConfiguration("content-types");
223        modelConf.addChild(contentTypesConf);
224        
225        ContentType cTypeCommonAncestor = null;
226        String cTypeCommonAncestorId = null;
227        
228        if (contents != null)
229        {
230            Set<String> contentTypeIds = new HashSet<>();
231            for (Content content : contents)
232            {
233                contentTypeIds.addAll(Arrays.asList(content.getTypes()));
234            }
235            
236            cTypeCommonAncestorId = _contentTypesHelper.getCommonAncestor(contentTypeIds);
237            if (StringUtils.isNotEmpty(cTypeCommonAncestorId))
238            {
239                cTypeCommonAncestor = _contentTypeExtensionPoint.getExtension(cTypeCommonAncestorId);
240            }
241        }
242        
243        if (cTypeCommonAncestor != null)
244        {
245            MutableConfiguration cTypeConf = new DefaultConfiguration("content-type");
246            cTypeConf.setAttribute("id", cTypeCommonAncestorId);
247            contentTypesConf.addChild(cTypeConf);
248        }
249        
250        // columns
251        MutableConfiguration columnsConf = new DefaultConfiguration("columns");
252        modelConf.addChild(columnsConf);
253        
254        MutableConfiguration columnsDefaultConf = new DefaultConfiguration("default");
255        columnsConf.addChild(columnsDefaultConf);
256        
257        if (columns != null && !columns.isEmpty())
258        {
259            _addColumnsConfiguration(columns, cTypeCommonAncestor, columnsDefaultConf);
260        }
261        else if (cTypeCommonAncestor != null && cTypeCommonAncestor.getMetadataSetForView("main") != null && cTypeCommonAncestor.hasMetadataDefinition("title"))
262        {
263            // metadata-ref="*"
264            MutableConfiguration columnConf = new DefaultConfiguration("column");
265            columnConf.setAttribute("metadata-ref", "*");
266            columnsDefaultConf.addChild(columnConf);
267            
268            // system-ref="*"
269            columnConf = new DefaultConfiguration("column");
270            columnConf.setAttribute("system-ref", "*");
271            columnsDefaultConf.addChild(columnConf);
272        }
273        
274        if (columnsDefaultConf.getChildren().length == 0)
275        {
276            if (getLogger().isInfoEnabled())
277            {
278                getLogger().info("No columns found. The default model will be used");
279            }
280            
281            return _searchUIModelEP.getExtension(defaultModelId);
282        }
283        else
284        {
285            localSearchModelManager.addComponent("script", null, "script-search-model", StaticSearchUIModel.class, conf);
286            localSearchModelManager.initialize();
287            return localSearchModelManager.lookup("script-search-model");
288        }
289    }
290    
291
292    @SuppressWarnings("unchecked")
293    private Object _processScriptResult(Map<String, Object> results, List<ScriptBinding> scriptBindings, Object scriptResult) throws ScriptException
294    {
295        if (scriptResult instanceof Content)
296        {
297            if (!results.containsKey("contents"))
298            {
299                results.put("contents", new ArrayList<>());
300            }
301            ((List<Object>) results.get("contents")).add(scriptResult);
302            
303            return ((Content) scriptResult).toString();
304        }
305        else if (scriptResult instanceof Map)
306        {
307            Map<Object, Object> elements = new HashMap<>();
308            Map scriptResultMap = (Map) scriptResult;
309            List<String> contents = _processScriptResultContents(results, scriptResultMap);
310            if (contents != null)
311            {
312                elements.put("results", contents);
313            }
314            
315            if (scriptResultMap.containsKey("columns"))
316            {
317                results.put("columns", scriptResultMap.get("columns"));
318            }
319            
320            // Map
321            for (Object key : scriptResultMap.keySet())
322            {
323                if (!"results".equals(key))
324                {
325                    Object value = scriptResultMap.get(key);
326                    elements.put(_processScriptResult(results, scriptBindings, key), _processScriptResult(results, scriptBindings, value));
327                }
328            }
329            return elements;
330        }
331        else if (scriptResult instanceof Iterator)
332        {
333            List<Object> objs = new ArrayList<>();
334            Iterator it = (Iterator) scriptResult;
335            while (it.hasNext())
336            {
337                objs.add(_processScriptResult(results, scriptBindings, it.next()));
338            }
339            return objs;
340        }
341        else if (scriptResult instanceof Collection)
342        {
343            List<Object> objs = new ArrayList<>();
344            for (Object obj : (Collection<Object>) scriptResult)
345            {
346                objs.add(_processScriptResult(results, scriptBindings, obj));
347            }
348            return objs;
349        }
350        else
351        {
352            return scriptResult;
353        }
354    }
355
356    @SuppressWarnings("unchecked")
357    private List<String> _processScriptResultContents(Map<String, Object> results, Map scriptResultMap)
358    {
359        if (scriptResultMap.containsKey("results"))
360        {
361            Object rawResults = scriptResultMap.get("results");
362            Collection<Content> rawResultCollection = null;
363            if (rawResults instanceof AmetysObjectIterable)
364            {
365                try (AmetysObjectIterable<Content> rawResultIterable = (AmetysObjectIterable<Content>) rawResults)
366                {
367                    rawResultCollection = rawResultIterable.stream().collect(Collectors.toList());
368                }
369            }
370            else if (rawResults instanceof Collection)
371            {
372                rawResultCollection = new LinkedList<>((Collection<Content>) rawResults);
373            }
374            else if (rawResults instanceof Map)
375            {
376                rawResultCollection = ((Map) rawResults).values();
377            }
378            
379            if (rawResultCollection != null)
380            {
381                results.put("contents", rawResultCollection);
382                return rawResultCollection.stream().map(content -> content.toString()).collect(Collectors.toList());
383            }
384        }
385        return null;
386    }
387    
388
389    private void _addColumnsConfiguration(Collection<String> columns, ContentType cTypeCommonAncestor, MutableConfiguration columnsDefaultConf)
390    {
391        for (String column : columns)
392        {
393            if (_systemPropEP.hasExtension(column))
394            {
395                MutableConfiguration columnConf = new DefaultConfiguration("column");
396                columnConf.setAttribute("system-ref", column);
397                columnsDefaultConf.addChild(columnConf);
398            }
399            else if ("title".equals(column))
400            {
401                MutableConfiguration columnConf = new DefaultConfiguration("column");
402                columnConf.setAttribute("metadata-ref", column);
403                columnsDefaultConf.addChild(columnConf);
404            }
405            else if (cTypeCommonAncestor != null)
406            {
407                String fullMetadataPath = StringUtils.replace(column, ".", "/");
408                Map<String, MetadataDefinition> metadataDefinitions = _contentTypesHelper.getMetadataDefinitions(ImmutableSet.of(fullMetadataPath), cTypeCommonAncestor);
409                
410                if (metadataDefinitions.getOrDefault(fullMetadataPath, null) == null)
411                {
412                    if (getLogger().isInfoEnabled())
413                    {
414                        getLogger().info("Unknown metadata '" + fullMetadataPath + "' in content type '" + cTypeCommonAncestor.getId() + "'");
415                    }
416                }
417                else
418                {
419                    MutableConfiguration columnConf = new DefaultConfiguration("column");
420                    columnConf.setAttribute("metadata-ref", column);
421                    _handleColumnConfiguration(columnConf, column);
422                    columnsDefaultConf.addChild(columnConf);
423                }
424            }
425        }
426    }
427    
428    /**
429     * Add/modify column configuration 
430     * @param columnConf The mutable configuration object that will be used to create the column.
431     * @param column The column identifier
432     */
433    protected void _handleColumnConfiguration(MutableConfiguration columnConf, String column)
434    {
435        // Title
436        // Add specific renderer
437        if ("title".equals(column))
438        {
439            MutableConfiguration rendererConf = new DefaultConfiguration("renderer");
440            rendererConf.setValue("Ametys.cms.content.EditContentsGrid.renderTitle");
441            
442            columnConf.addChild(rendererConf);
443        }
444    }
445
446    /**
447     * Convert content to json
448     * @param content The content
449     * @param searchColumns The columns, to know which value to fill
450     * @param extractor The properties extractor
451     * @return The json data
452     */
453    protected Map<String, Object> content2Json(Content content, Collection<SearchUIColumn> searchColumns, SearchModelContentValuesExtractor extractor)
454    {
455        Map<String, Object> contentData = new HashMap<>();
456        
457        contentData.put("id", content.getId());
458        contentData.put("name", content.getName());
459        
460        if (_contentHelper.isMultilingual(content) && _contentTypesHelper.getMetadataDefinition("title", content).getType() == MetadataType.MULTILINGUAL_STRING)
461        {
462            contentData.put("title", _contentHelper.getTitleVariants(content));
463        }
464        else
465        {
466            contentData.put("title", _contentHelper.getTitle(content));
467        }
468        
469        contentData.put("language", content.getLanguage());
470        contentData.put("contentTypes", content.getTypes());
471        contentData.put("mixins", content.getMixinTypes());
472        contentData.put("iconGlyph", _contentTypesHelper.getIconGlyph(content));
473        contentData.put("iconDecorator", _contentTypesHelper.getIconDecorator(content));
474        contentData.put("smallIcon", _contentTypesHelper.getSmallIcon(content));
475        contentData.put("mediumIcon", _contentTypesHelper.getMediumIcon(content));
476        contentData.put("largeIcon", _contentTypesHelper.getLargeIcon(content));
477        contentData.put("isSimple", _contentHelper.isSimple(content));
478        
479        contentData.putAll(extractor.getValues(content, null));
480        
481        return contentData;
482    }
483}