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