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