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