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.BufferedReader;
019import java.io.ByteArrayInputStream;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.InputStreamReader;
024import java.nio.charset.Charset;
025import java.nio.file.Files;
026import java.nio.file.Paths;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.HashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.Objects;
033import java.util.Optional;
034import java.util.function.Predicate;
035import java.util.stream.Collectors;
036
037import org.apache.avalon.framework.activity.Initializable;
038import org.apache.avalon.framework.component.Component;
039import org.apache.avalon.framework.service.ServiceException;
040import org.apache.avalon.framework.service.ServiceManager;
041import org.apache.avalon.framework.service.Serviceable;
042import org.apache.commons.io.IOUtils;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.tika.detect.DefaultEncodingDetector;
045import org.apache.tika.metadata.Metadata;
046import org.supercsv.io.CsvListReader;
047import org.supercsv.io.CsvMapReader;
048import org.supercsv.io.ICsvListReader;
049import org.supercsv.io.ICsvMapReader;
050import org.supercsv.prefs.CsvPreference;
051
052import org.ametys.cms.contenttype.ContentAttributeDefinition;
053import org.ametys.cms.contenttype.ContentType;
054import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
055import org.ametys.cms.contenttype.ContentTypesHelper;
056import org.ametys.core.ui.Callable;
057import org.ametys.core.user.CurrentUserProvider;
058import org.ametys.core.user.User;
059import org.ametys.core.user.UserIdentity;
060import org.ametys.core.user.UserManager;
061import org.ametys.core.util.I18nUtils;
062import org.ametys.core.util.mail.SendMailHelper;
063import org.ametys.core.util.mail.SendMailHelper.MailBuilder;
064import org.ametys.plugins.contentio.csv.SynchronizeModeEnumerator.ImportMode;
065import org.ametys.runtime.config.Config;
066import org.ametys.runtime.i18n.I18nizableText;
067import org.ametys.runtime.i18n.I18nizableTextParameter;
068import org.ametys.runtime.model.ElementDefinition;
069import org.ametys.runtime.model.ModelHelper;
070import org.ametys.runtime.model.ModelViewItemGroup;
071import org.ametys.runtime.model.View;
072import org.ametys.runtime.model.ViewElement;
073import org.ametys.runtime.model.ViewElementAccessor;
074import org.ametys.runtime.model.ViewItem;
075import org.ametys.runtime.model.exception.BadItemTypeException;
076import org.ametys.runtime.plugin.component.AbstractLogEnabled;
077
078import jakarta.mail.MessagingException;
079
080/**
081 * Import contents from an uploaded CSV file.
082 */
083public class ImportCSVFileHelper extends AbstractLogEnabled implements Component, Serviceable, Initializable
084{
085    /** Avalon Role */
086    public static final String ROLE = ImportCSVFileHelper.class.getName();
087
088    /** Result key containing updated content's ids */
089    public static final String RESULT_IMPORTED_COUNT = "importedCount";
090    /** Result key containing number of errors */
091    public static final String RESULT_NB_ERRORS = "nbErrors";
092    /** Result key containing number of warnings */
093    public static final String RESULT_NB_WARNINGS = "nbWarnings";
094    /** Result key containing a short an explanation for a general error */
095    public static final String RESULT_ERROR = "error";
096    /** Result key containing the filename */
097    public static final String RESULT_FILENAME = "fileName";
098    /** Column name of attribute path in CSV mapping */
099    public static final String MAPPING_COLUMN_ATTRIBUTE_PATH = "attributePath";
100    /** Column name of header in CSV mapping */
101    public static final String MAPPING_COLUMN_HEADER = "header";
102    /** Column name to identify if column is an ID in CSV mapping */
103    public static final String MAPPING_COLUMN_IS_ID = "isId";
104    /** Key in nested mapping to define the ID columns */
105    public static final String NESTED_MAPPING_ID = "id";
106    /** Key in nested mapping to define the values columns */
107    public static final String NESTED_MAPPING_VALUES = "values";
108    
109    private CurrentUserProvider _currentUserProvider;
110    private UserManager _userManager;
111    
112    private ContentTypeExtensionPoint _contentTypeEP;
113    private ContentTypesHelper _contentTypesHelper;
114    
115    private I18nUtils _i18nUtils;
116    private String _sysadminMail;
117
118    private CSVImporter _csvImporter;
119
120    @Override
121    public void service(ServiceManager serviceManager) throws ServiceException
122    {
123        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
124        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
125        
126        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
127        _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
128        
129        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
130        
131        _csvImporter = (CSVImporter) serviceManager.lookup(CSVImporter.ROLE);
132    }
133    
134    public void initialize() throws Exception
135    {
136        _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto");
137    }
138
139    /**
140     * Retrieves the values for import CSV parameters
141     * @param contentTypeId the configured content type identifier
142     * @return the values for import CSV parameters
143     * @throws Exception if an error occurs.
144     */
145    @Callable
146    public Map<String, Object> getImportCSVParametersValues(String contentTypeId) throws Exception
147    {
148        Map<String, Object> values = new HashMap<>();
149        
150        // Content types
151        List<String> contentTypeIds = StringUtils.isEmpty(contentTypeId) ? List.of() : List.of(contentTypeId);
152        Map<String, Object> filteredContentTypes = _contentTypesHelper.getContentTypesList(contentTypeIds, true, true, true, false, false);
153        values.put("availableContentTypes", filteredContentTypes.get("contentTypes"));
154        
155        // Recipient - get current user email
156        String currentUserEmail = null;
157        UserIdentity currentUser = _currentUserProvider.getUser();
158        
159        String login = currentUser.getLogin();
160        if (StringUtils.isNotBlank(login))
161        {
162            String userPopulationId = currentUser.getPopulationId();
163            User user = _userManager.getUser(userPopulationId, login);
164            currentUserEmail = user.getEmail();
165        }
166        values.put("defaultRecipient", currentUserEmail);
167        
168        return values;
169    }
170    
171    /**
172     * Gets the configuration for creating/editing a collection of synchronizable contents.
173     * @param contentTypeId Content type id
174     * @param formValues map of form values
175     * @param mappingValues list of header and content attribute mapping
176     * @return A map containing information about what is needed to create/edit a collection of synchronizable contents
177     * @throws IOException IOException while reading CSV
178     */
179    @SuppressWarnings("unchecked")
180    @Callable
181    public Map<String, Object> validateConfiguration(String contentTypeId, Map formValues, List<Map<String, Object>> mappingValues) throws IOException
182    {
183        Map<String, Object> result = new HashMap<>();
184
185        List<Map<String, Object>> filteredMappingValues = _filterMapping(mappingValues);
186        
187        List<String> contentAttributes = filteredMappingValues.stream()
188                .map(column -> column.get(MAPPING_COLUMN_ATTRIBUTE_PATH))
189                .map(String.class::cast)
190                .collect(Collectors.toList());
191
192        Map<String, Object> mapping = new HashMap<>();
193        filteredMappingValues.forEach(column -> generateNestedMapping(mapping, column));
194        
195        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
196        result.put("contentTypeName", contentType.getLabel());
197        View view = null;
198        for (String itemPath : contentAttributes)
199        {
200            if (!ModelHelper.hasModelItem(itemPath, List.of(contentType)))
201            {
202                result.put("error", "bad-mapping");
203                result.put("details", List.of(itemPath, contentTypeId));
204                return result;
205            }
206        }
207        
208        try 
209        {
210            view = View.of(contentType, contentAttributes.toArray(new String[contentAttributes.size()]));
211        }
212        catch (IllegalArgumentException | BadItemTypeException e)
213        {
214            getLogger().error("Error while creating view", e);
215            result.put("error", "cant-create-view");
216            result.put("details", contentTypeId);
217            return result;
218        }
219        
220        if (!mapping.containsKey(NESTED_MAPPING_ID))
221        {  
222            result.put("error", "missing-id");
223            return result;
224        } 
225        
226        for (ViewItem viewItem : view.getViewItems())
227        {
228            if (!_checkViewItemContainer(viewItem, (Map<String, Object>) mapping.get(NESTED_MAPPING_VALUES), result))
229            {
230                return result;
231            }
232        }
233        result.put("success", true);
234        return result;
235    }
236    /**
237     * Gets the configuration for creating/editing a collection of synchronizable contents.
238     * @param config get all CSV related parameters: path, separating/escaping char, charset and contentType
239     * @param formValues map of form values
240     * @param mappingValues list of header and content attribute mapping
241     * @return A map containing information about what is needed to create/edit a collection of synchronizable contents
242     * @throws IOException IOException while reading CSV
243     */
244    @Callable
245    public Map<String, Object> importContents(Map<String, Object> config, Map<String, Object> formValues, List<Map<String, Object>> mappingValues) throws IOException
246    {
247        Map<String, Object> result = new HashMap<>();
248        result.put(RESULT_FILENAME, config.get("fileName"));
249        
250        String path = (String) config.get("path");
251        String contentTypeId = (String) config.get("contentType");
252        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
253        
254        // CSV configuration
255        CsvPreference csvPreference = new CsvPreference.Builder(
256                config.getOrDefault("escaping-char", '"').toString().charAt(0),
257                config.getOrDefault("separating-char", ';').toString().charAt(0),
258                "\r\n"
259            )
260            .build();
261
262        String encoding = (String) config.get("charset");
263        Charset charset = Charset.forName(encoding);
264        
265        String language = (String) formValues.get("language");
266        int createAction = Integer.valueOf((String) formValues.getOrDefault("createAction", "1"));
267        int editAction = Integer.valueOf((String) formValues.getOrDefault("editAction", "2"));
268        String workflowName = (String) formValues.get("workflow");
269
270        ImportMode importMode = Optional.ofNullable((String) formValues.get("importMode")).filter(StringUtils::isNotEmpty).map(ImportMode::valueOf).orElse(ImportMode.CREATE_AND_UPDATE);
271        Optional<String> recipient = Optional.ofNullable(config.get("recipient"))
272                .filter(String.class::isInstance)
273                .map(String.class::cast)
274                .filter(StringUtils::isNotBlank);
275        
276        List<Map<String, Object>> filteredMappingValues = _filterMapping(mappingValues);
277        
278        try (
279            InputStream fileInputStream = new FileInputStream(path);
280            InputStream inputStream = IOUtils.buffer(fileInputStream);
281        )
282        {
283            result.putAll(_importContents(inputStream, csvPreference, charset, contentType, workflowName, language, createAction, editAction, filteredMappingValues, importMode));
284            
285            // If an exception occured and the recipient is empty, transfer the error mail to the configured sysadmin
286            if ("error".equals(result.get(RESULT_ERROR)) && recipient.isEmpty())
287            {
288                recipient = Optional.ofNullable(_sysadminMail)
289                        .filter(StringUtils::isNotBlank);
290            }
291            
292            if (recipient.isPresent())
293            {
294                _sendMail(result, recipient.get());
295            }
296        }
297        finally
298        {
299            deleteFile(path);
300        }
301
302        return result;
303    }
304    
305    /**
306     * Import contents from path, content type and language.
307     * CSV Excel north Europe preferences are used, charset is detected on the file, workflow is detected on content type, default creation (1) and
308     * edition (2) actions are used, values are automatically mapped from the header. Headers should contains a star (*) to detect identifier columns.
309     * @param inputStream The (buffered) input stream to the CSV resource
310     * @param contentType The content type
311     * @param language The language
312     * @param importMode The import mode
313     * @return the result of the CSV import
314     * @throws IOException if an error occurs
315     */
316    public Map<String, Object> importContents(InputStream inputStream, ContentType contentType, String language, ImportMode importMode) throws IOException
317    {
318        CsvPreference csvPreference = CsvPreference.EXCEL_NORTH_EUROPE_PREFERENCE;
319        
320        // Detect the charset of the current file
321        Charset charset = detectCharset(inputStream);
322        
323        // Copy the first 8192 bytes of the initial input stream to another input stream (to get headers)
324        inputStream.mark(8192);
325        try (InputStream headerStream = new ByteArrayInputStream(inputStream.readNBytes(8192)))
326        {
327            inputStream.reset();
328            
329            // Get headers
330            String[] headers = extractHeaders(headerStream, csvPreference, charset);
331            
332            // Get the mapping of the values
333            List<Map<String, Object>> mappingValues = getMapping(contentType, headers);
334            
335            // Get the default workflow associated to the content type
336            String workflowName = contentType.getDefaultWorkflowName().orElseThrow(() -> new IllegalArgumentException("The workflow can't be defined."));
337            
338            // Import contents (last use of the input stream)
339            return _importContents(inputStream, csvPreference, charset, contentType, workflowName, language, 1, 2, mappingValues, importMode);
340        }
341    }
342    
343    private Map<String, Object> _importContents(InputStream inputStream, CsvPreference csvPreference, Charset charset, ContentType contentType, String workflowName, String language, int createAction, int editAction, List<Map<String, Object>> mappingValues, ImportMode importMode)
344    {
345        Map<String, Object> result = new HashMap<>();
346        
347        List<String> contentAttributes = mappingValues.stream()
348            .map(column -> column.get(MAPPING_COLUMN_ATTRIBUTE_PATH))
349            .map(String.class::cast)
350            .collect(Collectors.toList());
351
352        Map<String, Object> mapping = new HashMap<>();
353        mappingValues.forEach(column -> generateNestedMapping(mapping, column));
354        
355        View view = View.of(contentType, contentAttributes.toArray(new String[contentAttributes.size()]));
356
357        try (
358            InputStreamReader inputStreamReader = new InputStreamReader(inputStream, charset);
359            BufferedReader reader = new BufferedReader(inputStreamReader);
360            ICsvListReader csvMapReadernew = new CsvListReader(reader, csvPreference)
361        )
362        {
363            Map<String, Object> importResult = _csvImporter.importContentsFromCSV(mapping, view, contentType, csvMapReadernew, createAction, editAction, workflowName, language, importMode);
364
365            @SuppressWarnings("unchecked")
366            List<String> contentIds = (List<String>) importResult.get(CSVImporter.RESULT_CONTENT_IDS);
367            
368            // fill RESULT_ERROR only if the content list is empty and if there are errors, as an empty file or no import in CREATE_ONLY and UPDATE_ONLY mode should not be considered as errors
369            if (contentIds.size() == 0 && (int) importResult.get(CSVImporter.RESULT_NB_ERRORS) > 0)
370            {
371                result.put(RESULT_ERROR, "no-import");
372            }
373            else
374            {
375                result.put(RESULT_IMPORTED_COUNT, contentIds.size());
376                result.put(RESULT_NB_ERRORS, importResult.get(CSVImporter.RESULT_NB_ERRORS));
377                result.put(RESULT_NB_WARNINGS, importResult.get(CSVImporter.RESULT_NB_WARNINGS));
378            }
379        }
380        catch (Exception e)
381        {  
382            getLogger().error("Error while importing contents", e);
383            result.put(RESULT_ERROR, "error");
384        }
385        
386        return result;
387    }
388    
389    private boolean _checkViewItemContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result)
390    {
391        if (viewItem instanceof ViewElement)
392        {
393            ViewElement viewElement = (ViewElement) viewItem;
394            ElementDefinition elementDefinition = viewElement.getDefinition();
395            if (elementDefinition instanceof ContentAttributeDefinition)
396            {
397                ViewElementAccessor viewElementAccessor = (ViewElementAccessor) viewElement;
398                ContentAttributeDefinition contentAttributeDefinition = (ContentAttributeDefinition) elementDefinition;
399                return _checkViewItemContentContainer(viewElementAccessor, mapping, result, contentAttributeDefinition);
400            }
401            else
402            {
403                return true;
404            }
405        }
406        else if (viewItem instanceof ModelViewItemGroup)
407        {
408            return _checkViewItemGroupContainer(viewItem, mapping, result);
409        }
410        else
411        {
412            result.put("error", "unknown-type");
413            result.put("details", viewItem.getName());
414            return false;
415        }
416    }
417
418    @SuppressWarnings("unchecked")
419    private boolean _checkViewItemContentContainer(ViewElementAccessor viewElementAccessor, Map<String, Object> mapping, Map<String, Object> result, ContentAttributeDefinition contentAttributeDefinition)
420    {
421
422        boolean success = true;
423        String contentTypeId = contentAttributeDefinition.getContentTypeId();
424        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
425        if (mapping.get(viewElementAccessor.getName()) instanceof String)
426        {
427            result.put("error", "string-as-container");
428            result.put("details", mapping.get(viewElementAccessor.getName()));
429            return false;
430        }
431        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName());
432        if (!nestedMap.containsKey(NESTED_MAPPING_ID) || ((List<String>) nestedMap.get(NESTED_MAPPING_ID)).isEmpty())
433        {
434            result.put("error", "missing-id-content");
435            result.put("details", List.of(contentType.getLabel(), contentAttributeDefinition.getName()));
436            return false;
437        }
438        Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(NESTED_MAPPING_VALUES));
439        for (ViewItem nestedViewItem : viewElementAccessor.getViewItems())
440        {
441            success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result);
442        }
443        return success;
444    }
445    
446    @SuppressWarnings("unchecked")
447    private boolean _checkViewItemGroupContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result)
448    {
449        boolean success = true;
450        ModelViewItemGroup modelViewItemGroup = (ModelViewItemGroup) viewItem;
451        List<ViewItem> elementDefinition = modelViewItemGroup.getViewItems();
452        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName());
453        
454        Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(NESTED_MAPPING_VALUES));
455                    
456        for (ViewItem nestedViewItem : elementDefinition)
457        {
458            success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result);
459        }
460        return success;
461    }
462
463    private static Map<String, Object> generateNestedMapping(Map<String, Object> mapping, Map<String, Object> column) 
464    {
465        return generateNestedMapping(mapping, (String) column.get(MAPPING_COLUMN_ATTRIBUTE_PATH), (String) column.get(ImportCSVFileHelper.MAPPING_COLUMN_HEADER), (boolean) column.get(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID)); 
466    }
467
468    @SuppressWarnings("unchecked")
469    private static Map<String, Object> generateNestedMapping(Map<String, Object> mapping, String attributePath, String column, boolean isId) 
470    {
471        Map<String, Object> values = (Map<String, Object>) mapping.computeIfAbsent(NESTED_MAPPING_VALUES, __ -> new HashMap<>());
472
473        int separatorIndex = attributePath.indexOf('/');
474        if (separatorIndex == -1)
475        {
476            if (isId)
477            {
478                List<String> ids = (List<String>) mapping.computeIfAbsent(NESTED_MAPPING_ID, __ -> new ArrayList<>());
479                ids.add(attributePath);
480            }
481            values.put(attributePath, column);
482        }
483        else
484        {
485            Map<String, Object> subValuesMapping = (Map<String, Object>) values.computeIfAbsent(attributePath.substring(0, separatorIndex), __ -> new HashMap<>());
486            generateNestedMapping(subValuesMapping, attributePath.substring(separatorIndex + 1), column, isId);
487        }
488        
489        mapping.put(NESTED_MAPPING_VALUES, values);
490        return mapping;
491    }
492
493    private void _sendMail(Map<String, Object> importResult, String recipient)
494    {
495        I18nizableText subject = _getMailSubject(importResult);
496        I18nizableText body = _getMailBody(importResult);
497        
498        try
499        {
500            MailBuilder mailBuilder = SendMailHelper.newMail()
501                                                    .withSubject(_i18nUtils.translate(subject))
502                                                    .withRecipient(recipient)
503                                                    .withTextBody(_i18nUtils.translate(body));
504            
505            mailBuilder.sendMail();
506        }
507        catch (MessagingException | IOException e)
508        {
509            if (getLogger().isWarnEnabled())
510            {
511                getLogger().warn("Unable to send the e-mail '" + subject  + "' to '" + recipient + "'", e);
512            }
513        }
514        catch (Exception e)
515        {
516            getLogger().error("An unknown error has occured while sending the mail.", e);
517        }
518    }
519    
520    private I18nizableText _getMailSubject(Map<String, Object> importResult)
521    {
522        Map<String, I18nizableTextParameter> i18nParams = Map.of("fileName", new I18nizableText((String) importResult.get(RESULT_FILENAME)));
523        if (importResult.containsKey(ImportCSVFileHelper.RESULT_ERROR))
524        {
525            return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_SUBJECT", i18nParams);
526        }
527        else
528        {
529            return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_SUBJECT", i18nParams);
530        }
531    }
532    
533    private I18nizableText _getMailBody(Map<String, Object> importResult)
534    {
535        Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
536        i18nParams.put("fileName", new I18nizableText((String) importResult.get(RESULT_FILENAME)));
537        
538        if (importResult.containsKey(ImportCSVFileHelper.RESULT_ERROR))
539        {
540            if ("no-import".equals(importResult.get(ImportCSVFileHelper.RESULT_ERROR)))
541            {
542                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_NO_IMPORT", i18nParams);
543            }
544            else
545            {
546                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_TEXT", i18nParams);
547            }
548        }
549        else
550        {
551            int nbErrors = (int) importResult.get(RESULT_NB_ERRORS);
552            int nbWarnings = (int) importResult.get(RESULT_NB_WARNINGS);
553            i18nParams.put("importedCount", new I18nizableText(String.valueOf(importResult.get(RESULT_IMPORTED_COUNT))));
554            i18nParams.put("nbErrors", new I18nizableText(String.valueOf(nbErrors)));
555            i18nParams.put("nbWarnings", new I18nizableText(String.valueOf(nbWarnings)));
556            
557            if (nbErrors == 0 && nbWarnings == 0)
558            {
559                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_BODY", i18nParams);
560            }
561            else if (nbWarnings == 0)
562            {
563                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS", i18nParams);
564            }
565            else if (nbErrors == 0)
566            {
567                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_WARNINGS", i18nParams);
568            }
569            else
570            {
571                return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS_AND_WARNINGS", i18nParams);
572            }
573        }
574    }
575
576    /**
577     * Delete the file related to the given path
578     * @param path path of the file
579     * @throws IOException if an error occurs while deleting the file
580     */
581    @Callable
582    public void deleteFile(String path) throws IOException 
583    {
584        Files.delete(Paths.get(path));
585    }
586    
587    /**
588     * Detect the charset of a given resource.
589     * @param inputStream The input stream to the resource, need to support {@link InputStream#mark(int)} and {@link InputStream#reset()}.
590     * @return the charset
591     * @throws IOException if an error occurs
592     */
593    public Charset detectCharset(InputStream inputStream) throws IOException
594    {
595        assert inputStream.markSupported();
596        DefaultEncodingDetector defaultEncodingDetector = new DefaultEncodingDetector();
597        return defaultEncodingDetector.detect(inputStream, new Metadata());
598    }
599
600    /**
601     * Extract headers from the CSV resource.
602     * @param inputStream The input stream to the resource
603     * @param csvPreference The CSV preference (separators)
604     * @param charset The charset of the resource
605     * @return the list of headers in file
606     * @throws IOException if an error occurs
607     */
608    public String[] extractHeaders(InputStream inputStream, CsvPreference csvPreference, Charset charset) throws IOException
609    {
610        try (
611            InputStreamReader inputStreamReader = new InputStreamReader(inputStream, charset);
612            BufferedReader reader = new BufferedReader(inputStreamReader);
613            ICsvMapReader mapReader = new CsvMapReader(reader, csvPreference);
614        )
615        {
616            String[] headers = mapReader.getHeader(true);
617            return headers;
618        }
619    }
620
621    /**
622     * Get the mapping from the headers and corresponding to the content type.
623     * @param contentType The content type
624     * @param headers The CSV headers
625     * @return the list of columns descriptions. An item of the list is one column, each column is defined by a header "header" (text defined into the CSV header), an
626     * attribute path "attributePath" (real path extracted from the header, empty if the model item does not exist) and an identifier flag "isId" (<code>true</code>
627     * if the header ends with a star "*").
628     */
629    public List<Map<String, Object>> getMapping(ContentType contentType, String[] headers)
630    {
631        return Arrays.asList(headers)
632            .stream()
633            .filter(StringUtils::isNotEmpty)
634            .map(header -> _transformHeaderToMap(contentType, header))
635            .collect(Collectors.toList());
636    }
637    
638    private Map<String, Object> _transformHeaderToMap(ContentType contentType, String header)
639    {
640        Map<String, Object> headerMap = new HashMap<>();
641        headerMap.put(ImportCSVFileHelper.MAPPING_COLUMN_HEADER, header);
642        headerMap.put(
643            MAPPING_COLUMN_ATTRIBUTE_PATH, 
644            Optional.of(header)
645                .map(s -> s.replace("*", ""))
646                .map(s -> s.replace(".", "/"))
647                .map(String::trim)
648                .filter(contentType::hasModelItem)
649                .orElse(StringUtils.EMPTY)
650        );
651        headerMap.put(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID, header.endsWith("*"));
652        return headerMap;
653    }
654    
655    private List<Map<String, Object>> _filterMapping(List<Map<String, Object>> mappingValues)
656    {
657        // Suppress empty attributePath
658        // Transform attributePath with . to attributePath with / (Autocompletion in IHM has . as separator, but the API need /)
659        return mappingValues.stream()
660            .map(column -> 
661                {
662                    Optional<String> attributePath = Optional.of(MAPPING_COLUMN_ATTRIBUTE_PATH)
663                        .map(column::get)
664                        .map(String.class::cast)
665                        .filter(StringUtils::isNotEmpty)
666                        .map(s -> s.replace(".", "/"));
667                    
668                    if (attributePath.isEmpty())
669                    {
670                        return null;
671                    }
672                    
673                    // Replace original column
674                    Map<String, Object> columnCopy = new HashMap<>(column);
675                    columnCopy.put(MAPPING_COLUMN_ATTRIBUTE_PATH, attributePath.get());
676                    return columnCopy;
677                }
678            )
679            .filter(Predicate.not(Objects::isNull))
680            .collect(Collectors.toList());
681    }
682}