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.web.content;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.ArrayList;
021import java.util.HashMap;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.Optional;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.servlet.multipart.Part;
040import org.apache.cocoon.xml.AttributesImpl;
041import org.apache.cocoon.xml.XMLUtils;
042import org.apache.commons.collections.MapUtils;
043import org.apache.commons.lang3.ArrayUtils;
044import org.apache.commons.lang3.StringUtils;
045import org.xml.sax.ContentHandler;
046import org.xml.sax.SAXException;
047
048import org.ametys.cms.contenttype.ContentAttributeDefinition;
049import org.ametys.cms.contenttype.ContentType;
050import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
051import org.ametys.cms.data.Binary;
052import org.ametys.cms.data.type.ModelItemTypeConstants;
053import org.ametys.cms.data.type.ResourceElementTypeHelper;
054import org.ametys.cms.repository.Content;
055import org.ametys.cms.search.query.AndQuery;
056import org.ametys.cms.search.query.ContentLanguageQuery;
057import org.ametys.cms.search.query.ContentTypeQuery;
058import org.ametys.cms.search.query.DocumentTypeQuery;
059import org.ametys.cms.search.query.Query;
060import org.ametys.cms.search.solr.SearcherFactory.Searcher;
061import org.ametys.cms.workflow.ContentWorkflowHelper;
062import org.ametys.cms.workflow.EditContentFunction;
063import org.ametys.core.upload.Upload;
064import org.ametys.core.upload.UploadManager;
065import org.ametys.core.user.CurrentUserProvider;
066import org.ametys.plugins.repository.AmetysObjectIterable;
067import org.ametys.plugins.repository.AmetysRepositoryException;
068import org.ametys.plugins.repository.RepositoryConstants;
069import org.ametys.plugins.repository.model.ViewHelper;
070import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
071import org.ametys.plugins.workflow.AbstractWorkflowComponent;
072import org.ametys.runtime.i18n.I18nizableText;
073import org.ametys.runtime.model.ElementDefinition;
074import org.ametys.runtime.model.ModelHelper;
075import org.ametys.runtime.model.ModelItem;
076import org.ametys.runtime.model.ModelViewItemGroup;
077import org.ametys.runtime.model.ViewItemContainer;
078import org.ametys.runtime.model.type.DataContext;
079import org.ametys.runtime.model.type.ElementType;
080import org.ametys.runtime.plugin.component.AbstractLogEnabled;
081import org.ametys.web.frontoffice.FrontOfficeSearcherFactory;
082
083import com.google.common.collect.ArrayListMultimap;
084import com.google.common.collect.Multimap;
085import com.opensymphony.workflow.WorkflowException;
086
087/**
088 * Helper for creating and editing a content from the submitted form
089 *
090 */
091public class FOContentCreationHelper extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
092{
093    /** The component role. */
094    public static final String ROLE = FOContentCreationHelper.class.getName();
095    
096    private UploadManager _uploadManager;
097
098    private CurrentUserProvider _currentUserProvider;
099
100    private ContentWorkflowHelper _contentWorkflowHelper;
101
102    private Context _context;
103
104    private ContentTypeExtensionPoint _cTypeExtPt;
105
106    private FrontOfficeSearcherFactory _searcherFactory;
107    
108    public void service(ServiceManager smanager) throws ServiceException
109    {
110        _cTypeExtPt = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
111        _uploadManager = (UploadManager) smanager.lookup(UploadManager.ROLE);
112        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
113        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
114        _searcherFactory = (FrontOfficeSearcherFactory) smanager.lookup(FrontOfficeSearcherFactory.ROLE);
115    }
116    
117    public void contextualize(Context context) throws ContextException
118    {
119        _context = context;
120    }
121    
122    /**
123     * Get the request
124     * @return the request
125     */
126    protected Request _getRequest()
127    {
128        return ContextHelper.getRequest(_context);
129    }
130    
131    /**
132     * SAX contents values for metadata of type CONTENT
133     * @param contentHandler The content handler to sax into
134     * @param contentType The content type
135     * @param rootTagName The root tag name
136     * @param language the current language
137     * @throws SAXException if an error occurs when saxing
138     */
139    public void saxContentValues(ContentHandler contentHandler, ContentType contentType, String rootTagName, String language) throws SAXException
140    {
141        List<ModelItem> contentAttributes = ModelHelper.findModelItemsByType(contentType, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID);
142        for (ModelItem contentAttribute : contentAttributes)
143        {
144            AttributesImpl attrs = new AttributesImpl();
145            attrs.addCDATAAttribute("name", contentAttribute.getPath().replace("/", "."));
146            XMLUtils.startElement(contentHandler, rootTagName, attrs);
147            _saxContentEnumeratorValue(contentHandler, (ContentAttributeDefinition) contentAttribute, language);
148            XMLUtils.endElement(contentHandler, rootTagName);
149        }
150    }
151
152
153    /**
154     * Sax enumeration value for enum or an attribute of type content
155     * @param contentHandler The content handler to sax into
156     * @param attribute The attribute of type content
157     * @param language The current language
158     * @throws SAXException If an error occurred while saxing
159     */
160    private void _saxContentEnumeratorValue(ContentHandler contentHandler, ContentAttributeDefinition attribute, String language) throws SAXException
161    {
162        Map<String, String> values = getContentValues(attribute.getContentTypeId(), language);
163        
164        XMLUtils.startElement(contentHandler, "enumeration");
165        for (Entry<String, String> entry : values.entrySet())
166        {
167            AttributesImpl attrItem = new AttributesImpl();
168            attrItem.addCDATAAttribute("value", entry.getKey());
169            XMLUtils.startElement(contentHandler, "item", attrItem);
170            XMLUtils.createElement(contentHandler, "label", entry.getValue());
171            XMLUtils.endElement(contentHandler, "item");
172        }
173        XMLUtils.endElement(contentHandler, "enumeration");
174    }
175    
176    /**
177     * Get values for contents enumeration
178     * @param cTypeId The id of content type
179     * @param language The current language
180     * @return The contents
181     */
182    protected Map<String, String> getContentValues(String cTypeId, String language)
183    {
184        try
185        {
186            Query query = null;
187            boolean multilingual = false;
188            if (StringUtils.isNotEmpty(cTypeId))
189            {
190                query = new ContentTypeQuery(cTypeId);
191                multilingual = _cTypeExtPt.getExtension(cTypeId).isMultilingual();
192            }
193            
194            if (!multilingual)
195            {
196                query = query != null ? new AndQuery(query, new ContentLanguageQuery(language)) : new ContentLanguageQuery(language);
197            }
198            
199            Searcher searcher = _searcherFactory.create().withQuery(query)
200                    .addFilterQuery(new DocumentTypeQuery("content"))
201                    .withLimits(0, Integer.MAX_VALUE);
202
203            AmetysObjectIterable<Content> contents = searcher.search();
204            
205            return contents.stream()
206                    .collect(Collectors.toMap(Content::getId, c -> c.getTitle(new Locale(language))))
207                    .entrySet()
208                    .stream()
209                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new));
210        }
211        catch (Exception e)
212        {
213            getLogger().error("Failed to get content enumeration for content type " +  cTypeId, e);
214            return MapUtils.EMPTY_MAP;
215        }
216    }
217    
218    /**
219     * Get the values for this content type from request
220     * @param request the request
221     * @param contentType the edited content type 
222     * @param viewName the view name
223     * @param errors The errors to fill
224     * @return The values
225     */
226    public Map<String, Object> getAndValidateFormValues(Request request, ContentType contentType, String viewName, Multimap<String, I18nizableText> errors)
227    {
228        Map<String, Object> values = _getFormValues(request, contentType.getView(viewName), StringUtils.EMPTY, errors);
229        errors.putAll(validateValues(values, contentType, viewName));
230        return values;
231    }
232    
233    private Map<String, Object> _getFormValues(Request request, ViewItemContainer viewItemContainer, String prefix, Multimap<String, I18nizableText> errors)
234    {
235        Map<String, Object> values = new HashMap<>();
236        
237        ViewHelper.visitView(viewItemContainer, 
238            (element, definition) -> {
239                // simple element
240                String name = definition.getName();
241                String dataPath = prefix + name;
242                _getElementValue(request, definition, dataPath, errors)
243                    .ifPresent(value -> values.put(name, value));
244            }, 
245            (group, definition) -> {
246                // composite
247                String name = definition.getName();
248                String updatedPrefix = prefix + name + ModelItem.ITEM_PATH_SEPARATOR;
249
250                values.put(name, _getFormValues(request, group, updatedPrefix, errors));
251            }, 
252            (group, definition) -> {
253                // repeater
254                String name = definition.getName();
255                String dataPath = prefix + name;
256                
257                List<Map<String, Object>> entries = _getRepeaterEntries(request, group, dataPath, errors);
258                values.put(name, entries);
259            }, 
260            group -> {
261                values.putAll(_getFormValues(request, group, prefix, errors));   
262            });
263
264        return values;
265    }
266    
267    @SuppressWarnings("unchecked")
268    private Optional<? extends Object> _getElementValue(Request request, ElementDefinition definition, String dataPath, Multimap<String, I18nizableText> errors)
269    {
270        Optional<? extends Object> value = Optional.empty();
271
272        String fieldName = StringUtils.replace(dataPath, ModelItem.ITEM_PATH_SEPARATOR, ".");
273        Object valueFromRequest = request.get(fieldName);
274        
275        if (valueFromRequest != null)
276        {
277            if (definition.isMultiple())
278            {
279                List<Object> multipleValue;
280                if (valueFromRequest instanceof List)
281                {
282                    multipleValue = (List<Object>) valueFromRequest;
283                }
284                else
285                {
286                    multipleValue = List.of(valueFromRequest);
287                }
288                List<? extends Object> valuesAsList = multipleValue.stream()
289                        .map(v -> _getTypedValue(v, definition, dataPath, errors))
290                        .filter(Optional::isPresent)
291                        .map(Optional::get)
292                        .collect(Collectors.toList());
293                
294                value = Optional.of(valuesAsList.toArray(new Object[valuesAsList.size()]));
295            }
296            else
297            {
298                Object singleValue;
299                if (valueFromRequest instanceof List)
300                {
301                    List<Object> valuesFromRequest = (List<Object>) valueFromRequest;
302                    singleValue = valuesFromRequest.isEmpty() ? null : valuesFromRequest.get(0);
303                }
304                else
305                {
306                    singleValue = valueFromRequest;
307                }
308                value = _getTypedValue(singleValue, definition, dataPath, errors);
309            }
310        }
311        
312        return value;
313    }
314    
315    private Optional<? extends Object> _getTypedValue(Object formValue, ElementDefinition definition, String dataPath, Multimap<String, I18nizableText> errors)
316    {
317        Optional<? extends Object> value;
318        ElementType type = definition.getType();
319        if (ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID.equals(type.getId()) || ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID.equals(type.getId()))
320        {
321            value = _getUploadFileValue((Part) formValue, dataPath, errors);
322        }
323        else if (org.ametys.runtime.model.type.ModelItemTypeConstants.BOOLEAN_TYPE_ID.equals(type.getId()))
324        {
325            value = Optional.of("on".equals(formValue) || "true".equals(formValue));
326        }
327        else
328        {
329            value = Optional.ofNullable(type.fromJSONForClient(formValue, DataContext.newInstance().withDataPath(dataPath)));
330        }
331        
332        return value;
333    }
334    
335    private List<Map<String, Object>> _getRepeaterEntries(Request request, ModelViewItemGroup viewItem, String dataPath, Multimap<String, I18nizableText> errors)
336    {
337        List<Map<String, Object>> entries = new ArrayList<>();
338
339        String fieldName = StringUtils.replace(dataPath, ModelItem.ITEM_PATH_SEPARATOR, ".");
340        int repeaterSize = Optional.ofNullable(request.getParameter(fieldName + ".size"))
341                .map(Integer::valueOf)
342                .orElse(0);
343        
344        for (int position = 1; position <= repeaterSize; position++)
345        {
346            String updatedPrefix = dataPath + "[" + position + "]" + ModelItem.ITEM_PATH_SEPARATOR;
347            entries.add(_getFormValues(request, viewItem, updatedPrefix, errors));
348        }
349        
350        return entries;
351    }
352    
353    /**
354     * Validate the given values
355     * @param values the values to validate
356     * @param contentType the edited content type 
357     * @param viewName the view name
358     * @return The errors if some values are not valid
359     */
360    public Multimap<String, I18nizableText> validateValues(Map<String, Object> values, ContentType contentType, String viewName)
361    {
362        Multimap<String, I18nizableText> errors = ArrayListMultimap.create();
363
364        Map<String, List<I18nizableText>> errorsAsMap = ViewHelper.validateValues(contentType.getView(viewName), Optional.ofNullable(values));
365        for (String dataPath : errorsAsMap.keySet())
366        {
367            errors.putAll(dataPath, errorsAsMap.get(dataPath));
368        }
369        
370        return errors;
371    }
372    
373    /**
374     * Create and edit a content
375     * @param initActionId The initial workflow action id for creation and edition
376     * @param contentTypeId The id of content type
377     * @param siteName The current site name
378     * @param contentName The content name
379     * @param contentTitle The content title
380     * @param language The content language
381     * @param values The submitted values
382     * @param workflowName The workflow name
383     * @param viewName The view name
384     * @return The workflow result
385     * @throws AmetysRepositoryException if an error occurs
386     * @throws WorkflowException if an error occurs
387     */
388    public Map<String, Object> createAndEditContent(int initActionId, String contentTypeId, String siteName, String contentName, String contentTitle, String language, Map<String, Object> values, String workflowName, String viewName) throws AmetysRepositoryException, WorkflowException
389    {
390        return createAndEditContent(initActionId, contentTypeId, siteName, contentName, contentTitle, language, values, workflowName, viewName, new HashMap<String, Object>());
391    }
392    
393    /**
394     * Create and edit a content
395     * @param initActionId The initial workflow action id for creation and edition
396     * @param contentTypeId The id of content type
397     * @param siteName The current site name
398     * @param contentName The content name
399     * @param contentTitle The content title
400     * @param language The content language
401     * @param values The submitted values
402     * @param workflowName The workflow name
403     * @param viewName The view name
404     * @param inputs The initial workflow inputs
405     * @return The workflow result
406     * @throws AmetysRepositoryException if an error occurs
407     * @throws WorkflowException if an error occurs
408     */
409    public Map<String, Object> createAndEditContent(int initActionId, String contentTypeId, String siteName, String contentName, String contentTitle, String language, Map<String, Object> values, String workflowName, String viewName, Map<String, Object> inputs) throws AmetysRepositoryException, WorkflowException
410    {
411        return createAndEditContent(initActionId, new String[] {contentTypeId}, ArrayUtils.EMPTY_STRING_ARRAY, siteName, contentName, contentTitle, language, values, workflowName, viewName, inputs);
412    }
413    
414    /**
415     * Create and edit a content
416     * @param initActionId The initial workflow action id for creation and edition
417     * @param contentTypeIds The new content types. Cannot be null. Cannot be empty.
418     * @param mixinIds The new mixins. Can be null. Can be empty.
419     * @param siteName The current site name
420     * @param contentName The content name
421     * @param contentTitle The content title
422     * @param language The content language
423     * @param values The values of the content attributes
424     * @param workflowName The workflow name
425     * @param viewName The view name
426     * @param inputs The initial workflow inputs
427     * @return The workflow result
428     * @throws AmetysRepositoryException if an error occurs
429     * @throws WorkflowException if an error occurs
430     */
431    public Map<String, Object> createAndEditContent(int initActionId, String[] contentTypeIds, String[] mixinIds, String siteName, String contentName, String contentTitle, String language, Map<String, Object> values, String workflowName, String viewName, Map<String, Object> inputs) throws AmetysRepositoryException, WorkflowException
432    {
433        Request request = _getRequest();
434        
435        // Retrieve the current workspace.
436        String currentWsp = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
437        
438        try
439        {
440            // Force the default workspace.
441            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
442            
443            // Workflow parameters.
444            inputs.put(org.ametys.web.workflow.CreateContentFunction.SITE_KEY, siteName);
445            
446            Map<String, Object> contextParameters = new HashMap<>();
447            contextParameters.put(EditContentFunction.QUIT, true);
448            contextParameters.put(EditContentFunction.VALUES_KEY, values);
449            if (viewName != null)
450            {
451                contextParameters.put(EditContentFunction.VIEW_NAME, viewName);
452            }
453            
454            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
455            
456            return _contentWorkflowHelper.createContent(workflowName, initActionId, contentName, contentTitle, contentTypeIds, mixinIds, language, null, null, inputs);
457        }
458        finally
459        {
460            // Restore context
461            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWsp);
462        }
463        
464    }
465    
466    private Optional<Binary> _getUploadFileValue(Part partUploaded, String dataPath, Multimap<String, I18nizableText> errors)
467    {
468        try (InputStream is = partUploaded.getInputStream())
469        {
470            Upload upload = _uploadManager.storeUpload(_currentUserProvider.getUser(), partUploaded.getFileName(), is);
471            return Optional.of(ResourceElementTypeHelper.binaryFromUpload(upload));
472        }
473        catch (IOException e)
474        {
475            getLogger().error("Unable to store uploaded file: " + partUploaded, e);
476            errors.put(dataPath, new I18nizableText("plugin.web", "PLUGINS_WEB_FO_HELPER_UPLOAD_FILE_ERROR"));
477            return Optional.empty();
478        }
479    }
480    
481    
482}