001/*
002 *  Copyright 2013 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.repository;
017
018import java.io.BufferedInputStream;
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.Reader;
024import java.nio.charset.Charset;
025import java.util.ArrayList;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.stream.Stream;
030
031import org.apache.avalon.framework.parameters.Parameters;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.cocoon.acting.ServiceableAction;
035import org.apache.cocoon.environment.ObjectModelHelper;
036import org.apache.cocoon.environment.Redirector;
037import org.apache.cocoon.environment.Request;
038import org.apache.cocoon.environment.SourceResolver;
039import org.apache.cocoon.servlet.multipart.Part;
040import org.apache.cocoon.servlet.multipart.PartOnDisk;
041import org.apache.cocoon.servlet.multipart.RejectedPart;
042import org.apache.commons.io.FilenameUtils;
043import org.apache.commons.io.input.BOMInputStream;
044import org.apache.commons.lang3.ArrayUtils;
045import org.apache.commons.lang3.StringUtils;
046import org.apache.tika.parser.txt.CharsetDetector;
047import org.supercsv.io.CsvListReader;
048import org.supercsv.io.ICsvListReader;
049import org.supercsv.prefs.CsvPreference;
050
051import org.ametys.cms.contenttype.ContentType;
052import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
053import org.ametys.cms.contenttype.ContentTypesHelper;
054import org.ametys.cms.contenttype.MetadataDefinition;
055import org.ametys.cms.contenttype.MetadataType;
056import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
057import org.ametys.cms.workflow.ContentWorkflowHelper;
058import org.ametys.cms.workflow.EditContentFunction;
059import org.ametys.core.cocoon.JSonReader;
060import org.ametys.core.util.JSONUtils;
061import org.ametys.plugins.repository.AmetysRepositoryException;
062import org.ametys.plugins.workflow.AbstractWorkflowComponent;
063
064import com.opensymphony.workflow.WorkflowException;
065
066/**
067 * Imports simple contents from a CSV file
068 *
069 */
070public class ImportSimpleContentsAction extends ServiceableAction
071{
072    private static final String[] _ALLOWED_EXTENSIONS = new String[] {"txt", "csv"};
073    
074    private ContentTypeExtensionPoint _contentTypeEP;
075    private ContentWorkflowHelper _contentWorkflowHelper;
076    private ContentTypeExtensionPoint _cTypeEP;
077
078    private ContentTypesHelper _contentTypeHelper;
079
080    private JSONUtils _jsonUtils;
081    
082    @Override
083    public void service(ServiceManager smanager) throws ServiceException
084    {
085        super.service(smanager);
086        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
087        _contentTypeHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
088        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
089        _cTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
090        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
091    }
092    
093    @Override
094    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
095    {
096        Map<String, Object> result = new HashMap<>();
097        
098        Request request = ObjectModelHelper.getRequest(objectModel);
099        String cTypeId = request.getParameter("contentType");
100        String lang = request.getParameter("contentLanguage");
101        
102        ContentType cType = _contentTypeEP.getExtension(cTypeId);
103        if (!cType.isSimple())
104        {
105            result.put("error", "invalid-content-type");
106            request.setAttribute(JSonReader.OBJECT_TO_READ, result);
107            return EMPTY_MAP;
108        }
109        
110        Part part = (Part) request.get("importFile");
111        if (part instanceof RejectedPart)
112        {
113            result.put("error", "rejected-file");
114            request.setAttribute(JSonReader.OBJECT_TO_READ, result);
115            return EMPTY_MAP;
116        }
117        
118        PartOnDisk uploadedFilePart = (PartOnDisk) part;
119        File uploadedFile = (uploadedFilePart != null) ? uploadedFilePart.getFile() : null;
120        String filename = (uploadedFilePart != null) ? uploadedFilePart.getFileName().toLowerCase() : null;
121        
122        if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS))
123        {
124            result.put("error", "invalid-extension");
125            request.setAttribute(JSonReader.OBJECT_TO_READ, result);
126            return EMPTY_MAP;
127        }
128        
129        String workflowName = request.getParameter("workflowName");
130        int initWorkflowActionId = Integer.valueOf(request.getParameter("initWorkflowActionId"));
131        int workflowEditActionId = Integer.valueOf(request.getParameter("editWorkflowActionId"));
132
133        try
134        {
135            List<String> contentIds = _createSimpleContents(cTypeId, lang, workflowName, initWorkflowActionId, workflowEditActionId, uploadedFile);
136            result.put("contentIds", contentIds);
137            result.put("success", "true");
138        }
139        catch (Exception e)
140        {
141            result.put("error", "true");
142            result.put("errorMessage", e.getMessage());
143            
144            getLogger().error("Unable to create simple contents.", e);
145        }
146        
147        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
148        
149        return EMPTY_MAP;
150    }
151
152    private List<String> _createSimpleContents (String contentTypeId, String lang, String workflowName, int initActionId, int editActionId, File uploadedFile) throws IOException, WorkflowException
153    {
154        List<String> contentIds = new ArrayList<>();
155        
156        try (ICsvListReader csvReader = new CsvListReader(_getReader(new BOMInputStream(new FileInputStream(uploadedFile))), CsvPreference.EXCEL_NORTH_EUROPE_PREFERENCE))
157        {
158            String[] metadataNames = csvReader.getHeader(true);
159            
160            // Remove empty columns (supportable only on the last column, for other cases, it can have side effects), spaces and tabs before and after the metadata name
161            metadataNames = Stream.of(metadataNames)
162                .filter(StringUtils::isNotBlank)
163                .map(String::trim)
164                .toArray(String[]::new);
165            
166            int titleIndex = ArrayUtils.indexOf(metadataNames, "title");
167            titleIndex = titleIndex != -1 ? titleIndex : 0;
168            
169            List<String> parts = new ArrayList<>();
170            while ((parts = csvReader.read()) != null)
171            {
172                Content content = _createSimpleContent(contentTypeId, lang, parts.get(titleIndex).trim(), workflowName, initActionId);
173                _editSimpleContent(content, metadataNames, parts, editActionId, lang);
174                contentIds.add(content.getId());
175            }
176        }
177        // FIXME catch WorkflowException and remove all created contents?
178        return contentIds;
179    }
180    
181    /**
182     * Get a reader on the data stream that detects the charset.
183     * @param in the data stream.
184     * @return the reader with the correct character set.
185     */
186    protected Reader _getReader(InputStream in)
187    {
188        // Use Tika/ICU to detect the file charset.
189        BufferedInputStream buffIs = new BufferedInputStream(in);
190        
191        CharsetDetector detector = new CharsetDetector();
192        return detector.getReader(buffIs, Charset.defaultCharset().name());
193    }
194    
195    private Map<String, Object> _editSimpleContent (Content content, String[] metadataNames, List<String> values, int actionId, String defaultLang) throws AmetysRepositoryException, WorkflowException
196    {
197        Map<String, String> jsValues = new HashMap<>();
198        
199        for (int i = 0; i < metadataNames.length; i++)
200        {
201            if (!metadataNames[i].equals("title") && values.size() > i)
202            {
203                MetadataDefinition metadataDef = _contentTypeHelper.getMetadataDefinition(metadataNames[i], content);
204                if (metadataDef.getType() == MetadataType.MULTILINGUAL_STRING)
205                {
206                    Map<String, String> rawValues = new HashMap<>();
207                    rawValues.put(defaultLang, StringUtils.trim(values.get(i)));
208                    jsValues.put(EditContentFunction.FORM_ELEMENTS_PREFIX + metadataNames[i], _jsonUtils.convertObjectToJson(rawValues));
209                }
210                else
211                {
212                    jsValues.put(EditContentFunction.FORM_ELEMENTS_PREFIX + metadataNames[i], StringUtils.trim(values.get(i)));
213                }
214            }
215        }
216
217        Map<String, Object> contextParameters = new HashMap<>();
218        contextParameters.put("quit", true);
219        contextParameters.put("values", jsValues);
220        contextParameters.put(EditContentFunction.METADATA_SET_PARAM, null);
221        
222        Map<String, Object> inputs = new HashMap<>();
223        inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
224        
225        Map<String, Object> result = _contentWorkflowHelper.doAction((WorkflowAwareContent) content, actionId, inputs);
226        return result;
227    }
228    
229    private Content _createSimpleContent(String cTypeId, String lang, String contentTitle, String workflowName, int actionId) throws WorkflowException
230    {
231        ContentType cType = _cTypeEP.getExtension(cTypeId);
232        MetadataDefinition titleMetaDef = cType.getMetadataDefinition(Content.METADATA_TITLE);
233        
234        // CMS-9474 Import reference table : Title with only figures are not supported
235        String contentName = contentTitle;
236        if (!_startsWithAlpha(contentName))
237        {
238            contentName = "content-" + contentName;
239        }
240        
241        Map<String, Object> result = null;
242        if (titleMetaDef.getType() == MetadataType.MULTILINGUAL_STRING)
243        {
244            Map<String, String> titleVariants = new HashMap<>();
245            titleVariants.put(lang, contentTitle);
246            
247            result = _contentWorkflowHelper.createContent(workflowName, actionId, contentName, titleVariants, new String[] {cTypeId}, new String[0]);
248        }
249        else
250        {
251            result = _contentWorkflowHelper.createContent(workflowName, actionId, contentName, contentTitle, new String[] {cTypeId}, new String[0], lang);
252        }
253        
254        return (Content) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
255    }
256    
257    private boolean _startsWithAlpha(String stringToTest)
258    {
259        return stringToTest.toLowerCase().matches("^[a-zàèìòùáéíóúýâêîôûãñõäëïöüÿçßØøåæœ].*$");
260    }
261}