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