001/*
002 *  Copyright 2020 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.plugins.contentio.csv;
017
018import java.io.File;
019import java.io.FileInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.nio.charset.Charset;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.nio.file.Paths;
026import java.nio.file.StandardCopyOption;
027import java.util.ArrayList;
028import java.util.HashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.UUID;
032import java.util.function.Function;
033import java.util.stream.Collectors;
034
035import org.apache.avalon.framework.component.Component;
036import org.apache.avalon.framework.context.Context;
037import org.apache.avalon.framework.context.ContextException;
038import org.apache.avalon.framework.context.Contextualizable;
039import org.apache.avalon.framework.service.ServiceException;
040import org.apache.avalon.framework.service.ServiceManager;
041import org.apache.avalon.framework.service.Serviceable;
042import org.apache.cocoon.components.ContextHelper;
043import org.apache.cocoon.environment.Request;
044import org.apache.cocoon.servlet.multipart.Part;
045import org.apache.cocoon.servlet.multipart.PartOnDisk;
046import org.apache.cocoon.servlet.multipart.RejectedPart;
047import org.apache.commons.io.FileUtils;
048import org.apache.commons.io.FilenameUtils;
049import org.apache.commons.io.IOUtils;
050import org.apache.commons.lang3.StringUtils;
051import org.supercsv.prefs.CsvPreference;
052
053import org.ametys.cms.contenttype.ContentType;
054import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
055import org.ametys.cms.data.type.ModelItemTypeConstants;
056import org.ametys.core.cocoon.JSonReader;
057import org.ametys.core.ui.Callable;
058import org.ametys.plugins.workflow.support.WorkflowHelper;
059import org.ametys.runtime.i18n.I18nizableText;
060import org.ametys.runtime.model.Enumerator;
061import org.ametys.runtime.model.ModelItem;
062import org.ametys.runtime.servlet.RuntimeConfig;
063
064/**
065 * Import contents from an uploaded CSV file.
066 */
067public class ImportCSVFile implements Serviceable, Contextualizable, Component
068{
069    /** Avalon Role */
070    public static final String ROLE = ImportCSVFile.class.getName();
071    
072    /** The ametys home folder where csv files are stored before import */
073    static final String CONTENTIO_STORAGE_DIRECTORY = "contentio/temp"; // FIXME should be relate to AmetysTmp not AmetysHome CONTENTIO-334
074    
075    private static final String[] _ALLOWED_EXTENSIONS = new String[] {"txt", "csv"};
076    
077    private ContentTypeExtensionPoint _contentTypeEP;
078    
079    private WorkflowHelper _workflowHelper;
080    
081    private ImportCSVFileHelper _importCSVFileHelper;
082
083    private Enumerator _synchronizeModeEnumerator;
084    
085    private Context _context;
086    
087    @Override
088    public void service(ServiceManager serviceManager) throws ServiceException
089    {
090        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
091        _workflowHelper = (WorkflowHelper) serviceManager.lookup(WorkflowHelper.ROLE);
092        _importCSVFileHelper = (ImportCSVFileHelper) serviceManager.lookup(ImportCSVFileHelper.ROLE);
093        _synchronizeModeEnumerator = (Enumerator) serviceManager.lookup(SynchronizeModeEnumerator.ROLE);
094    }
095    
096    public void contextualize(Context context) throws ContextException
097    {
098        _context = context;
099    }
100    
101    /**
102     * Import CSV file
103     * @param escapingChar The escaping character
104     * @param separatingChar The separating character
105     * @param contentTypeId The content type id
106     * @param part The CSV file's Part
107     * @return The result of the import
108     * @throws Exception If an error occurs
109     */
110    @Callable (rights = "Plugins_ContentIO_ImportFile", context = "/cms")
111    public Map importCSVFile(String escapingChar, String separatingChar, String contentTypeId, Part part) throws Exception
112    {
113        Request request = ContextHelper.getRequest(_context);
114        
115        Map<String, Object> result = new HashMap<>();
116        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
117
118        if (part instanceof RejectedPart || part == null)
119        {
120            result.put("success", false);
121            result.put("error", "rejected");
122        }
123        else
124        {
125            PartOnDisk uploadedFilePart = (PartOnDisk) part;
126            File uploadedFile = uploadedFilePart.getFile();
127            
128            String filename = uploadedFilePart.getFileName().toLowerCase();
129            
130            if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS))
131            {
132                result.put("error", "invalid-extension");
133                request.setAttribute(JSonReader.OBJECT_TO_READ, result);
134                return result;
135            }
136            
137            try (
138                InputStream fileInputStream = new FileInputStream(uploadedFile);
139                InputStream inputStream = IOUtils.buffer(fileInputStream);
140            )
141            {
142                Charset charset = _importCSVFileHelper.detectCharset(inputStream);
143                result.put("charset", charset);
144                String[] headers = _importCSVFileHelper.extractHeaders(inputStream, _getCSVPreference(escapingChar, separatingChar), charset);
145              
146                if (headers == null)
147                {
148                    result.put("error", "no-header");
149                    request.setAttribute(JSonReader.OBJECT_TO_READ, result);
150                    return result;
151                }
152                
153                List<Map<String, Object>> mapping = _importCSVFileHelper.getMapping(contentType, headers);
154                result.put("mapping", mapping);
155                
156                // Build a map to count how many attribute are there for each group
157                Map<String, Long> attributeCount = mapping.stream()
158                        .map(map -> map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH))
159                        .map(String.class::cast)
160                        .filter(StringUtils::isNotEmpty)
161                        .map(attributePath -> {
162                            String attributePrefix = "";
163                            int endIndex = attributePath.lastIndexOf("/");
164                            if (endIndex != -1)
165                            {
166                                attributePrefix = attributePath.substring(0, endIndex);
167                            }
168                            return attributePrefix;
169                        })
170                        .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
171                
172                // If an attribute is the only for its group, it is the identifier
173                for (Map<String, Object> map : mapping)
174                {
175                    String attributePath = (String) map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH);
176                    String attributePrefix = StringUtils.EMPTY;
177                    int endIndex = attributePath.lastIndexOf("/");
178                    if (endIndex != -1)
179                    {
180                        attributePrefix = attributePath.substring(0, endIndex);
181                    }
182    
183                    boolean parentIsContent;
184                    // If there is no prefix, it is an attribute of the content we want to import
185                    if (attributePrefix.equals(StringUtils.EMPTY))
186                    {
187                        parentIsContent = true;
188                    }
189                    // Otherwise, check the modelItem
190                    else if (contentType.hasModelItem(attributePrefix))
191                    {
192                        ModelItem modelItem = contentType.getModelItem(attributePrefix);
193                        String modelTypeId = modelItem.getType().getId();
194                        parentIsContent = ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(modelTypeId);
195                    }
196                    else
197                    {
198                        parentIsContent = false;
199                    }
200                    
201                    // If an attribute is the only one of its level, and is part of a content, consider it as the identifier
202                    if (attributeCount.getOrDefault(attributePrefix, 0L).equals(1L) && parentIsContent && StringUtils.isNotBlank(attributePath))
203                    {
204                        map.put(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID, true);
205                    }
206                }
207                
208                String importId = UUID.randomUUID().toString();
209                _copyFile(uploadedFile.toPath(), importId);
210                result.put("importId", importId);
211                result.put("success", true);
212                result.put("workflows", _getWorkflows());
213                result.put("defaultWorkflow", contentType.getDefaultWorkflowName().orElse(null));
214                result.put("fileName", uploadedFile.getName());
215                
216                List<Map<String, Object>> synchronizeModes = ((Map<String, I18nizableText>) _synchronizeModeEnumerator.getEntries())
217                        .entrySet()
218                        .stream()
219                        .map(entry -> Map.of("value", entry.getKey(), "label", entry.getValue()))
220                        .toList();
221                result.put("synchronizeModes", synchronizeModes);
222            }
223        }
224        
225        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
226        return result;
227    }
228    
229    private void _copyFile(Path path, String importId) throws IOException
230    {
231        File contentIOStorageDir = FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), CONTENTIO_STORAGE_DIRECTORY);
232        
233        if (!contentIOStorageDir.exists())
234        {
235            if (!contentIOStorageDir.mkdirs())
236            {
237                throw new IOException("Unable to create monitoring directory: " + contentIOStorageDir);
238            }
239        }
240        Files.copy(path, Paths.get(contentIOStorageDir.getPath(), importId + ".csv"), StandardCopyOption.REPLACE_EXISTING);
241    }
242    
243    private CsvPreference _getCSVPreference(String escapingChar, String separatingChar)
244    {
245        char separating = separatingChar.charAt(0);
246        char escaping = escapingChar.charAt(0);
247        
248        if (separating == escaping)
249        {
250            throw new IllegalArgumentException("Separating character can not be equals to escaping character");
251        }
252        return new CsvPreference.Builder(escaping, separating, "\r\n").build();
253    }
254    
255    /**
256     * getWorkflows
257     * @return map of workflows
258     */
259    private List<Map<String, Object>> _getWorkflows()
260    {
261        List<Map<String, Object>> workflows = new ArrayList<>();
262        String[] workflowNames = _workflowHelper.getWorkflowNames();
263        for (String workflowName : workflowNames)
264        {
265            Map<String, Object> workflowMap = new HashMap<>();
266            workflowMap.put("value", workflowName);
267            workflowMap.put("label", _workflowHelper.getWorkflowLabel(workflowName));
268            workflows.add(workflowMap);
269        }
270        return workflows;
271    }
272}