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.parameters.Parameters;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.cocoon.acting.ServiceableAction;
039import org.apache.cocoon.environment.ObjectModelHelper;
040import org.apache.cocoon.environment.Redirector;
041import org.apache.cocoon.environment.Request;
042import org.apache.cocoon.environment.SourceResolver;
043import org.apache.cocoon.servlet.multipart.Part;
044import org.apache.cocoon.servlet.multipart.PartOnDisk;
045import org.apache.cocoon.servlet.multipart.RejectedPart;
046import org.apache.commons.io.FileUtils;
047import org.apache.commons.io.FilenameUtils;
048import org.apache.commons.io.IOUtils;
049import org.apache.commons.lang3.StringUtils;
050import org.supercsv.prefs.CsvPreference;
051
052import org.ametys.cms.contenttype.ContentType;
053import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
054import org.ametys.cms.data.type.ModelItemTypeConstants;
055import org.ametys.core.cocoon.JSonReader;
056import org.ametys.plugins.workflow.support.WorkflowProvider;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.model.ModelItem;
059import org.ametys.runtime.servlet.RuntimeConfig;
060
061/**
062 * Import contents from an uploaded CSV file.
063 */
064public class ImportCSVFileAction extends ServiceableAction
065{
066
067    private static final String[] _ALLOWED_EXTENSIONS = new String[] {"txt", "csv"};
068
069    private static final String CONTENTIO_STORAGE_DIRECTORY = "contentio/temp";
070
071    private ContentTypeExtensionPoint _contentTypeEP;
072    
073    private WorkflowProvider _workflowProvider;
074    
075    private ImportCSVFileHelper _importCSVFileHelper;
076    
077    @Override
078    public void service(ServiceManager serviceManager) throws ServiceException
079    {
080        super.service(serviceManager);
081        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
082        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
083        _importCSVFileHelper = (ImportCSVFileHelper) manager.lookup(ImportCSVFileHelper.ROLE);
084    }
085    
086    @Override
087    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
088    {
089        Request request = ObjectModelHelper.getRequest(objectModel);
090        
091        Map<String, Object> result = new HashMap<>();
092        String contentTypeId = (String) request.get("contentType");
093        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
094        Part part = (Part) request.get("file");
095        if (part instanceof RejectedPart || part == null)
096        {
097            result.put("success", false);
098            result.put("error", "rejected");
099        }
100        else
101        {
102            PartOnDisk uploadedFilePart = (PartOnDisk) part;
103            File uploadedFile = uploadedFilePart.getFile();
104            
105            String filename = uploadedFilePart.getFileName().toLowerCase();
106            
107            if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS))
108            {
109                result.put("error", "invalid-extension");
110                request.setAttribute(JSonReader.OBJECT_TO_READ, result);
111                return EMPTY_MAP;
112            }
113            
114            try (
115                InputStream fileInputStream = new FileInputStream(uploadedFile);
116                InputStream inputStream = IOUtils.buffer(fileInputStream);
117            )
118            {
119                Charset charset = _importCSVFileHelper.detectCharset(inputStream);
120                result.put("charset", charset);
121                String[] headers = _importCSVFileHelper.extractHeaders(inputStream, _getCSVPreference(request), charset);
122              
123                if (headers == null)
124                {
125                    result.put("error", "no-header");
126                    request.setAttribute(JSonReader.OBJECT_TO_READ, result);
127                    return EMPTY_MAP;
128                }
129                
130                List<Map<String, Object>> mapping = _importCSVFileHelper.getMapping(contentType, headers);
131                result.put("mapping", mapping);
132                
133                // Build a map to count how many attribute are there for each group
134                Map<String, Long> attributeCount = mapping.stream()
135                        .map(map -> map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH))
136                        .map(String.class::cast)
137                        .filter(StringUtils::isNotEmpty)
138                        .map(attributePath -> {
139                            String attributePrefix = "";
140                            int endIndex = attributePath.lastIndexOf("/");
141                            if (endIndex != -1)  
142                            {
143                                attributePrefix = attributePath.substring(0, endIndex);
144                            }
145                            return attributePrefix;
146                        })
147                        .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
148                
149                // If an attribute is the only for its group, it is the identifier
150                for (Map<String, Object> map : mapping)
151                {
152                    String attributePath = (String) map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH);
153                    String attributePrefix = StringUtils.EMPTY;
154                    int endIndex = attributePath.lastIndexOf("/");
155                    if (endIndex != -1)  
156                    {
157                        attributePrefix = attributePath.substring(0, endIndex);
158                    }
159    
160                    boolean parentIsContent;
161                    // If there is no prefix, it is an attribute of the content we want to import
162                    if (attributePrefix.equals(StringUtils.EMPTY))
163                    {
164                        parentIsContent = true;
165                    }
166                    // Otherwise, check the modelItem 
167                    else if (contentType.hasModelItem(attributePrefix))
168                    {
169                        ModelItem modelItem = contentType.getModelItem(attributePrefix);
170                        String modelTypeId = modelItem.getType().getId();
171                        parentIsContent = ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(modelTypeId);
172                    }
173                    else
174                    {
175                        parentIsContent = false;
176                    }
177                    
178                    // If an attribute is the only one of its level, and is part of a content, consider it as the identifier
179                    if (attributeCount.getOrDefault(attributePrefix, 0L).equals(1L) && parentIsContent && StringUtils.isNotBlank(attributePath))
180                    {
181                        map.put(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID, true);
182                    }
183                }
184                
185                String newPath = _copyFile(uploadedFile.toPath());
186                result.put("path", newPath);
187                result.put("success", true);
188                result.put("workflows", _getWorkflows());
189                result.put("defaultWorkflow", contentType.getDefaultWorkflowName().orElse(null));
190                result.put("fileName", uploadedFile.getName());
191            }
192        }
193        
194        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
195        return EMPTY_MAP;
196    }
197    
198    private String _copyFile(Path path) throws IOException
199    {
200        File contentIOStorageDir = FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), CONTENTIO_STORAGE_DIRECTORY);
201        
202        if (!contentIOStorageDir.exists())
203        {
204            if (!contentIOStorageDir.mkdirs())
205            {
206                throw new IOException("Unable to create monitoring directory: " + contentIOStorageDir);
207            }
208        }
209        String id = UUID.randomUUID().toString() + ".csv";
210        Path copy = Files.copy(path, Paths.get(contentIOStorageDir.getPath(), id), StandardCopyOption.REPLACE_EXISTING);
211        
212        return copy.toString();
213        
214    }
215    
216    private CsvPreference _getCSVPreference(Request request)
217    {
218        String escapingChar = (String) request.get("escaping-char");
219        String separatingChar = (String) request.get("separating-char");
220        char separating = separatingChar.charAt(0);
221        char escaping = escapingChar.charAt(0);
222        
223        if (separating == escaping)
224        {
225            throw new IllegalArgumentException("Separating character can not be equals to escaping character");
226        }
227        return new CsvPreference.Builder(escaping, separating, "\r\n").build();
228    }
229    
230    /**
231     * getWorkflows
232     * @return map of workflows
233     */
234    private List<Map<String, Object>> _getWorkflows()
235    {
236        List<Map<String, Object>> workflows = new ArrayList<>();
237        String[] workflowNames = _workflowProvider.getAmetysObjectWorkflow().getWorkflowNames();
238        for (String workflowName : workflowNames)
239        {
240            Map<String, Object> workflowMap = new HashMap<>();
241            workflowMap.put("value", workflowName);
242            workflowMap.put("label", new I18nizableText("application", "WORKFLOW_" + workflowName));
243            workflows.add(workflowMap);
244        }
245        return workflows;
246    }
247}