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