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