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.IOException;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.Set;
029import java.util.function.Function;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang3.LocaleUtils;
038import org.apache.commons.lang3.StringUtils;
039import org.supercsv.io.ICsvListReader;
040import org.supercsv.util.Util;
041
042import org.ametys.cms.ObservationConstants;
043import org.ametys.cms.contenttype.ContentAttributeDefinition;
044import org.ametys.cms.contenttype.ContentType;
045import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
046import org.ametys.cms.data.ContentValue;
047import org.ametys.cms.data.type.BaseMultilingualStringElementType;
048import org.ametys.cms.data.type.impl.MultilingualStringRepositoryElementType;
049import org.ametys.cms.indexing.solr.SolrIndexHelper;
050import org.ametys.cms.repository.Content;
051import org.ametys.cms.repository.ContentQueryHelper;
052import org.ametys.cms.repository.ModifiableDefaultContent;
053import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
054import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
055import org.ametys.cms.workflow.ContentWorkflowHelper;
056import org.ametys.cms.workflow.CreateContentFunction;
057import org.ametys.core.util.I18nUtils;
058import org.ametys.plugins.contentio.csv.SynchronizeModeEnumerator.ImportMode;
059import org.ametys.plugins.contentio.in.ContentImportException;
060import org.ametys.plugins.repository.AmetysObjectIterable;
061import org.ametys.plugins.repository.AmetysObjectResolver;
062import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
063import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
064import org.ametys.plugins.repository.data.holder.group.Repeater;
065import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
066import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
067import org.ametys.plugins.repository.data.holder.values.SynchronizableValue.Mode;
068import org.ametys.plugins.repository.data.type.ModelItemTypeConstants;
069import org.ametys.plugins.repository.metadata.MultilingualString;
070import org.ametys.plugins.repository.metadata.MultilingualStringHelper;
071import org.ametys.plugins.repository.query.expression.AndExpression;
072import org.ametys.plugins.repository.query.expression.Expression;
073import org.ametys.plugins.repository.query.expression.Expression.Operator;
074import org.ametys.plugins.repository.query.expression.ExpressionContext;
075import org.ametys.plugins.repository.query.expression.MultilingualStringExpression;
076import org.ametys.plugins.repository.query.expression.StringExpression;
077import org.ametys.runtime.model.ElementDefinition;
078import org.ametys.runtime.model.ModelItem;
079import org.ametys.runtime.model.ModelViewItemGroup;
080import org.ametys.runtime.model.View;
081import org.ametys.runtime.model.ViewElement;
082import org.ametys.runtime.model.ViewElementAccessor;
083import org.ametys.runtime.model.ViewItem;
084import org.ametys.runtime.model.type.ElementType;
085import org.ametys.runtime.plugin.component.AbstractLogEnabled;
086
087import com.opensymphony.workflow.WorkflowException;
088
089/**
090 * Import contents from an uploaded CSV file.
091 */
092public class CSVImporter extends AbstractLogEnabled implements Component, Serviceable
093{
094    
095    /** Avalon Role */
096    public static final String ROLE = CSVImporter.class.getName();
097
098    /** Result key containing updated content's ids */
099    public static final String RESULT_CONTENT_IDS = "contentIds";
100    /** Result key containing number of errors */
101    public static final String RESULT_NB_ERRORS = "nbErrors";
102    /** Result key containing number of warnings */
103    public static final String RESULT_NB_WARNINGS = "nbWarnings";
104
105    private ContentWorkflowHelper _contentWorkflowHelper;
106
107    private ContentTypeExtensionPoint _contentTypeEP;
108    
109    private AmetysObjectResolver _resolver;
110
111    private I18nUtils _i18nUtils;
112
113    private SolrIndexHelper _solrIndexHelper;
114    
115    public void service(ServiceManager smanager) throws ServiceException
116    {
117        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
118        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
119        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
120        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
121        _solrIndexHelper = (SolrIndexHelper) smanager.lookup(SolrIndexHelper.ROLE);
122    }
123
124    /**
125     * Extract contents from CSV file
126     * @param mapping mapping of content attributes and CSV file header
127     * @param view View of importing content
128     * @param contentType content type to import
129     * @param listReader mapReader to parse CSV file
130     * @param createAction creation action id
131     * @param editAction edition action id
132     * @param workflowName workflow name
133     * @param language language of created content.
134     * @param importMode The import mode
135     * @param parentContent Optional parent content
136     * @param additionalTransientVars The optional additional transient vars to add to the transient vars
137     * @return list of created contents
138     * @throws IOException IOException while reading CSV
139     */
140    public Map<String, Object> importContentsFromCSV(Map<String, Object> mapping, View view, ContentType contentType, ICsvListReader listReader, int createAction, int editAction, String workflowName, String language, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws IOException
141    {
142        try
143        {
144            _solrIndexHelper.pauseSolrCommitForEvents(_getIndexationEvents());
145            
146            List<String> contentIds = new ArrayList<>();
147            String[] columns = listReader.getHeader(true);
148            int nbErrors = 0;
149            int nbWarnings = 0;
150            List<String> row = null;
151            
152            while ((row = listReader.read()) != null)
153            {
154                try
155                {
156                    if (listReader.length() != columns.length)
157                    {
158                        getLogger().error("[{}] Import from CSV file: content skipped because of invalid row: {}", contentType.getId(), row);
159                        nbErrors++;
160                        continue;
161                    }
162    
163                    Map<String, String> rowMap = new HashMap<>();
164                    Util.filterListToMap(rowMap, columns, row);
165                    List<ViewItem> errors = new ArrayList<>();
166                    SynchronizeResult synchronizeResult = processContent(view, rowMap, contentType, mapping, createAction, editAction, workflowName, language, errors, importMode, parentContent, additionalTransientVars);
167                    Optional<ModifiableWorkflowAwareContent> content = synchronizeResult.content();
168                    
169                    // If we are in CREATE_ONLY mode but content was not created, do not add it to the contents list
170                    if (content.isPresent() && (importMode != ImportMode.CREATE_ONLY || synchronizeResult.isCreated()))
171                    {
172                        contentIds.add(content.get().getId());
173                    }
174                    
175                    if (!errors.isEmpty())
176                    {
177                        nbWarnings++;
178                    }
179                }
180                catch (Exception e)
181                {
182                    nbErrors++;
183                    getLogger().error("[{}] Import from CSV file: error importing the content on line {}", contentType.getId(), listReader.getLineNumber(), e);
184                }
185            }
186            
187            Map<String, Object> results = new HashMap<>();
188            results.put(RESULT_CONTENT_IDS, contentIds);
189            results.put(RESULT_NB_ERRORS, nbErrors);
190            results.put(RESULT_NB_WARNINGS, nbWarnings);
191            return results;
192        }
193        finally
194        {
195            _solrIndexHelper.restartSolrCommitForEvents(_getIndexationEvents());
196        }
197    }
198    
199    private String[] _getIndexationEvents()
200    {
201        return new String[] {
202            ObservationConstants.EVENT_CONTENT_ADDED,
203            ObservationConstants.EVENT_CONTENT_MODIFIED,
204            ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED,
205            ObservationConstants.EVENT_CONTENT_VALIDATED
206        };
207    }
208    
209    /**
210     * Process content for CSV import or synchronization
211     * @param view The view
212     * @param row The row containing the values
213     * @param contentType The content type
214     * @param mapping The mapping
215     * @param createAction The create action
216     * @param editAction The edit action
217     * @param workflowName The wokflow name
218     * @param language The language
219     * @param errors The errors
220     * @param importMode The import mode
221     * @param parentContent The parent content
222     * @param additionalTransientVars The additional transient vars
223     * @return The synchronize result
224     * @throws Exception If an error occurs
225     */
226    protected SynchronizeResult processContent(View view, Map<String, String> row, ContentType contentType, Map<String, Object> mapping, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws Exception
227    {
228        @SuppressWarnings("unchecked")
229        List<String> attributeIdNames = (List<String>) mapping.get(ImportCSVFileHelper.NESTED_MAPPING_ID);
230        @SuppressWarnings("unchecked")
231        Map<String, Object> mappingValues = (Map<String, Object>) mapping.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES);
232
233        SynchronizeResult synchronizeResult = _synchronizeContent(row, contentType, view, attributeIdNames, mappingValues, createAction, editAction, workflowName, language, errors, importMode, parentContent, additionalTransientVars);
234        return synchronizeResult;
235    }
236
237    private void _editContent(int editAction, View view, Map<String, Object> values, ModifiableWorkflowAwareContent content) throws WorkflowException
238    {
239        Collection<ModelItem> viewItemsDiff = content.getDifferences(view, values);
240        if (!viewItemsDiff.isEmpty())
241        {
242            _contentWorkflowHelper.editContent(content, values, editAction, View.of(viewItemsDiff.toArray(ModelItem[]::new)));
243        }
244    }
245    
246    private Object _getValue(Optional<? extends Content> parentContent, ViewItem viewItem, Map<String, Object> mapping, Map<String, String> row, int createAction, int editAction, String language, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars) throws Exception
247    {
248        if (viewItem instanceof ViewElement viewElement)
249        {
250            ElementDefinition elementDefinition = viewElement.getDefinition();
251            if (elementDefinition instanceof ContentAttributeDefinition contentAttributeDefinition && viewElement instanceof ViewElementAccessor viewElementAccessor)
252            {
253                return _getContentAttributeDefinitionValues(parentContent, mapping, row, createAction, editAction, language, viewElementAccessor, contentAttributeDefinition, errors, importMode, additionalTransientVars);
254            }
255            else
256            {
257                return _getAttributeDefinitionValues(parentContent, mapping, row, elementDefinition, language, prefix);
258            }
259        }
260        else if (viewItem instanceof ModelViewItemGroup modelViewItemGroup)
261        {
262            List<ViewItem> children = modelViewItemGroup.getViewItems();
263            @SuppressWarnings("unchecked")
264            Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName());
265            @SuppressWarnings("unchecked")
266            Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES));
267            if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelViewItemGroup.getDefinition().getType().getId()))
268            {
269                return _getRepeaterValues(parentContent, modelViewItemGroup, row, createAction, editAction, language, children, nestedMap, errors, prefix, importMode, additionalTransientVars);
270            }
271            else
272            {
273                return _getCompositeValues(parentContent, viewItem, row, createAction, editAction, language, children, nestedMapValues, errors, prefix, importMode, additionalTransientVars);
274            }
275        }
276        else
277        {
278            errors.add(viewItem);
279            throw new RuntimeException("Import from CSV file: unsupported type of ViewItem for view: " + viewItem.getName());
280        }
281    }
282
283    private Map<String, Object> _getCompositeValues(Optional<? extends Content> parentContent, ViewItem viewItem, Map<String, String> row, int createAction, int editAction,
284            String language, List<ViewItem> children, Map<String, Object> nestedMapValues, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars)
285    {
286        Map<String, Object> compositeValues = new HashMap<>();
287        for (ViewItem child : children)
288        {
289            try
290            {
291                compositeValues.put(child.getName(), _getValue(parentContent, child, nestedMapValues, row, createAction, editAction, language, errors, prefix + viewItem.getName() + ModelItem.ITEM_PATH_SEPARATOR, importMode, additionalTransientVars));
292            }
293            catch (Exception e)
294            {
295                errors.add(viewItem);
296                getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
297            }
298        }
299        return compositeValues;
300    }
301
302    private SynchronizableRepeater _getRepeaterValues(Optional<? extends Content> parentContent, ModelViewItemGroup viewItem, Map<String, String> row, int createAction, int editAction, String language,
303            List<ViewItem> children, Map<String, Object> nestedMap, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars)
304    {
305        @SuppressWarnings("unchecked")
306        Map<String, Object> mappingValues = (Map<String, Object>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES);
307        @SuppressWarnings("unchecked")
308        List<String> attributeIdNames = (List<String>) nestedMap.getOrDefault(ImportCSVFileHelper.NESTED_MAPPING_ID, List.of());
309        Map<String, Object> repeaterValues = new HashMap<>();
310        List<Map<String, Object>> repeaterValuesList = new ArrayList<>();
311        List<Integer> indexList = new ArrayList<>();
312
313        Optional<ModelAwareRepeater> repeater = parentContent.map(p -> p.getRepeater(prefix + viewItem.getName()));
314        
315        if (repeater.isPresent() && !attributeIdNames.isEmpty() && _allAttributesFilled(mappingValues, attributeIdNames))
316        {
317            indexList = repeater.get()
318                .getEntries()
319                .stream()
320                .filter(entry ->
321                {
322                    return attributeIdNames.stream()
323                        .allMatch(attributeName ->
324                        {
325                            // Get the entry value
326                            Object entryValue = entry.getValue(attributeName);
327                            
328                            // Get the row value for the attribute (from CSV file)
329                            Object rowValue = Optional.of(attributeName)
330                                    .map(mappingValues::get)
331                                    .map(String.class::cast)
332                                    .map(row::get)
333                                    .orElse(null);
334                            
335                            // Transform the row value to the right type then compare entry value and row value
336                            return Optional.of(attributeName)
337                                .map(viewItem::getModelViewItem)
338                                .map(ViewElement.class::cast)
339                                .map(ViewElement::getDefinition)
340                                .map(ElementDefinition::getType)
341                                .map(def -> def.castValue(rowValue))
342                                .map(value -> value.equals(entryValue))
343                                .orElse(false);
344                        });
345                })
346                .map(ModelAwareRepeaterEntry::getPosition)
347                .collect(Collectors.toList());
348        }
349        
350        // If entries match with attribute ids, we replace the first entries
351        // Else if the repeater exists, we assume we are on the next model item after the last one
352        // Else (the repeater doesn't exist, we set the rowIndex to first index
353        Integer rowIndex = indexList.isEmpty() ? repeater.map(Repeater::getSize).orElse(1) : indexList.get(0);
354        
355        for (ViewItem child : children)
356        {
357            try
358            {
359                Object entryValues = _getValue(parentContent, child, mappingValues, row, createAction, editAction, language, errors, prefix + viewItem.getName() + "[" + rowIndex + "]" + ModelItem.ITEM_PATH_SEPARATOR, importMode, additionalTransientVars);
360                if (entryValues != null)
361                {
362                    repeaterValues.put(child.getName(), entryValues);
363                }
364            }
365            catch (Exception e)
366            {
367                errors.add(viewItem);
368                getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
369            }
370        }
371        repeaterValuesList.add(repeaterValues);
372        
373        if (indexList.isEmpty())
374        {
375            return SynchronizableRepeater.appendOrRemove(repeaterValuesList, Set.of());
376        }
377        else
378        {
379            // If several rows match the id, only replace the first but add a warning
380            if (indexList.size() > 1)
381            {
382                errors.add(viewItem);
383            }
384            return SynchronizableRepeater.replace(repeaterValuesList, List.of(rowIndex));
385        }
386    }
387    
388    private Object _getContentAttributeDefinitionValues(Optional<? extends Content> parentContent, Map<String, Object> mapping, Map<String, String> row,
389            int createAction, int editAction, String language, ViewElementAccessor viewElementAccessor, ContentAttributeDefinition contentAttributeDefinition, List<ViewItem> errors, ImportMode importMode, Map<String, Object> additionalTransientVars) throws Exception
390    {
391        String contentTypeId = contentAttributeDefinition.getContentTypeId();
392        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
393        @SuppressWarnings("unchecked")
394        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName());
395        @SuppressWarnings("unchecked")
396        Map<String, Object> mappingValues = (Map<String, Object>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES);
397        @SuppressWarnings("unchecked")
398        List<String> attributeIdNames = (List<String>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_ID);
399        if (_allAttributesFilled(mappingValues, attributeIdNames))
400        {
401            View view = new View();
402            view.addViewItems(viewElementAccessor.getViewItems());
403            SynchronizeResult synchronizeResult = _synchronizeContent(row, contentType, view, attributeIdNames, mappingValues, createAction, editAction, null, language, errors, importMode, parentContent, additionalTransientVars);
404            
405            Optional<ModifiableWorkflowAwareContent> attachedContent = synchronizeResult.content();
406            
407            if (synchronizeResult.hasKey && attachedContent.isEmpty() && importMode == ImportMode.UPDATE_ONLY)
408            {
409                errors.add(viewElementAccessor);
410            }
411            
412            // If content is multiple, we keep the old value list and we check if content was already inside, and add it otherwise
413            if (attachedContent.isPresent() && contentAttributeDefinition.isMultiple())
414            {
415                Optional<ContentValue[]> multipleContents = parentContent.map(c -> c.getValue(contentAttributeDefinition.getPath()));
416                if (!_containsContent(attachedContent.get(), multipleContents))
417                {
418                    // If there is no list or if it is empty, add it. Otherwise, we have to check if it is inside.
419                    SynchronizableValue syncValue = new SynchronizableValue(List.of(attachedContent.get()));
420                    syncValue.setMode(Mode.APPEND);
421                    return syncValue;
422                }
423                else if (multipleContents.isPresent())
424                {
425                    // return existing values as otherwise the values would be erased
426                    SynchronizableValue syncValue = new SynchronizableValue(Arrays.asList(multipleContents.get()));
427                    syncValue.setMode(Mode.REPLACE);
428                    return syncValue;
429                }
430            }
431            else
432            {
433                return attachedContent.orElse(null);
434            }
435        }
436        return null;
437    }
438    
439    private boolean _containsContent(ModifiableWorkflowAwareContent attachedContent, Optional<ContentValue[]> multipleContents)
440    {
441        return multipleContents
442                .map(Arrays::stream)
443                .orElseGet(Stream::empty)
444                .map(ContentValue::getContentId)
445                .anyMatch(valueFromContent -> valueFromContent.equals(attachedContent.getId()));
446    }
447    
448    private Object _getAttributeDefinitionValues(Optional<? extends Content> parentContent, Map<String, Object> mapping, Map<String, String> row, ElementDefinition elementDefinition, String language, String prefix)
449    {
450        ElementType elementType = elementDefinition.getType();
451        String elementName = elementDefinition.getName();
452        String elementColumn = (String) mapping.get(elementName);
453        String valueAsString = row.get(elementColumn);
454        
455        Object value;
456        if (elementType instanceof BaseMultilingualStringElementType && !MultilingualStringHelper.matchesMultilingualStringPattern(valueAsString))
457        {
458            MultilingualString multilingualString = new MultilingualString();
459            multilingualString.add(LocaleUtils.toLocale(language), valueAsString);
460            value = multilingualString;
461        }
462        else
463        {
464            value = elementType.castValue(valueAsString);
465        }
466        
467        if (elementDefinition.isMultiple())
468        {
469            // Build path with index for repeaters.
470            String pathWithIndex = prefix + elementDefinition.getName();
471            
472            Optional<Object[]> values = parentContent.map(c -> c.getValue(pathWithIndex));
473            if (!_containsValue(value, parentContent.map(c -> c.getValue(pathWithIndex))))
474            {
475                // If there is no list or if it is empty, add it. Otherwise, we have to check if it is inside.
476                // If there is no parentContent, still append as we want to fill the values map anyway.
477                SynchronizableValue syncValue = new SynchronizableValue(value != null ? List.of(value) : List.of());
478                syncValue.setMode(Mode.APPEND);
479                return syncValue;
480            }
481            else if (values.isPresent())
482            {
483                // return existing values as otherwise the values would be erased
484                SynchronizableValue syncValue = new SynchronizableValue(Arrays.asList(values.get()));
485                syncValue.setMode(Mode.REPLACE);
486                return syncValue;
487            }
488        }
489        else
490        {
491            return value;
492        }
493        
494        return null;
495    }
496    
497    private boolean _containsValue(Object value, Optional<Object[]> multipleValues)
498    {
499        return multipleValues
500                .map(Arrays::stream)
501                .orElseGet(Stream::empty)
502                .anyMatch(valueFromContent -> valueFromContent.equals(value));
503    }
504
505    private SynchronizeResult _synchronizeContent(Map<String, String> row, ContentType contentType, View view, List<String> attributeIdNames, Map<String, Object> mappingValues, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws Exception
506    {
507        SynchronizeResult synchronizeResult = _getOrCreateContent(mappingValues, row, contentType, Optional.ofNullable(workflowName), createAction, language, attributeIdNames, parentContent, importMode, additionalTransientVars);
508        Optional<ModifiableWorkflowAwareContent> content = synchronizeResult.content();
509        
510        // If we are on CREATE_ONLY mode and content already exists, or if we are on UPDATE_ONLY mode and content does not exists, stop recursivity
511        if (importMode == ImportMode.CREATE_ONLY && !synchronizeResult.isCreated() || importMode == ImportMode.UPDATE_ONLY && content.isEmpty())
512        {
513            return synchronizeResult;
514        }
515        
516        Map<String, Object> values = _getValues(content, row, view, mappingValues, createAction, editAction, language, errors, importMode, additionalTransientVars);
517        if (!values.isEmpty())
518        {
519            if (content.isEmpty())
520            {
521                // Throw this exception only when values are filled, as an empty content should not trigger any warning
522                throw new ContentImportException("Can't create and fill content of content type '" + contentType.getId() + "' and following values '" + values + "' : at least one of those identifiers is null : " + attributeIdNames);
523            }
524            else
525            {
526                try
527                {
528                    _editContent(editAction, view, values, content.get());
529                }
530                catch (WorkflowException e)
531                {
532                    errors.addAll(view.getViewItems());
533                    getLogger().error("[{}] Import from CSV file: error editing the content [{}] after import, some values have not been set", contentType.getId(), content.get().getId(), e);
534                }
535            }
536        }
537        
538        return synchronizeResult;
539    }
540    
541    /**
542     * Returns a list of additional expression to retrieve the existing content
543     * @param mapping The mapping
544     * @param row The row
545     * @param contentType The content type
546     * @param parentContent The parent content
547     * @param additionalTransientVars Additional transients vars
548     * @param valuesUsedAsIds The values used as ids, if some values are added in expression, they can be added in this list to be printed in error logs
549     * @return a list of additional expression to retrieve the existing content
550     */
551    protected List<Expression> additionalExpressionToRetrieveContent(Map<String, Object> mapping, Map<String, String> row, ContentType contentType, Optional<? extends Content> parentContent, Map<String, Object> additionalTransientVars, List<String> valuesUsedAsIds)
552    {
553        // No additional expression by default
554        return List.of();
555    }
556    
557    private SynchronizeResult _getOrCreateContent(Map<String, Object> mapping, Map<String, String> row, ContentType contentType, Optional<String> workflowName, int createAction, String language, List<String> attributeIdNames, Optional<? extends Content> parentContent, ImportMode importMode, Map<String, Object> additionalTransientVars) throws ContentImportException, WorkflowException
558    {
559        AndExpression expression = new AndExpression();
560        List<String> values = new ArrayList<>();
561        
562        for (String attributeName : attributeIdNames)
563        {
564            ModelItem modelItem = contentType.getModelItem(attributeName);
565            String attributePath = (String) mapping.get(attributeName);
566            String value = row.get(attributePath);
567            values.add(value);
568            
569            if (value == null)
570            {
571                return new SynchronizeResult(false, Optional.empty(), false);
572            }
573            
574            // Get content
575            if (modelItem.getType() instanceof MultilingualStringRepositoryElementType)
576            {
577                expression.add(new MultilingualStringExpression(attributeName, Operator.EQ, value, language));
578            }
579            else
580            {
581                expression.add(new StringExpression(attributeName, Operator.EQ, value));
582            }
583        }
584        
585        expression.addAll(additionalExpressionToRetrieveContent(mapping, row, contentType, parentContent, additionalTransientVars, values));
586
587        expression.add(_contentTypeEP.createHierarchicalCTExpression(contentType.getId()));
588        
589        if (!contentType.isMultilingual())
590        {
591            expression.add(new StringExpression("language", Operator.EQ, language, ExpressionContext.newInstance().withInternal(true)));
592        }
593        
594        String xPathQuery = ContentQueryHelper.getContentXPathQuery(expression);
595        AmetysObjectIterable<ModifiableDefaultContent> matchingContents = _resolver.query(xPathQuery);
596        if (matchingContents.getSize() > 1)
597        {
598            throw new ContentImportException("More than one content found for type " + contentType.getId() + " with "
599                    + attributeIdNames + " as identifier and " + values + " as value");
600        }
601        else if (matchingContents.getSize() == 1)
602        {
603            return new SynchronizeResult(false, Optional.of(matchingContents.iterator().next()), true);
604        }
605        else if (importMode == ImportMode.UPDATE_ONLY)
606        {
607            return new SynchronizeResult(false, Optional.empty(), true);
608        }
609        
610        // Create content
611
612        if (contentType.isAbstract())
613        {
614            throw new ContentImportException("Can not create content for type " + contentType.getId() + " with "
615                    + attributeIdNames + " as identifier and " + values + " as value, the content type is abstract");
616        }
617        
618        Map<String, Object> result;
619        String title;
620        if (mapping.containsKey("title"))
621        {
622            title = row.get(mapping.get("title"));
623        }
624        else
625        {
626            title = _i18nUtils.translate(contentType.getDefaultTitle(), language);
627        }
628        
629        
630        String finalWorkflowName = workflowName.or(contentType::getDefaultWorkflowName)
631                                               .orElseThrow(() -> new ContentImportException("No workflow specified for content type " + contentType.getId() + " with "
632                                                       + attributeIdNames + " as identifier and " + values + " as value"));
633
634        Map<String, Object> inputs = new HashMap<>();
635        inputs.put(CreateContentFunction.INITIAL_VALUE_SUPPLIER, new Function<List<String>, Object>()
636        {
637            public Object apply(List<String> keys)
638            {
639                // Browse the mapping to find the column related to the attribute
640                Object nestedValue = mapping;
641                for (String key : keys)
642                {
643                    nestedValue = ((Map) nestedValue).get(key);
644                    // If nestedValue is null, the attribute is absent from the map, no value can be found
645                    if (nestedValue == null)
646                    {
647                        return null;
648                    }
649                    // If nestedValue is a map, the key is a complex element such a content or a composite,
650                    // we need to keep browsing the map to find the column
651                    if (nestedValue instanceof Map)
652                    {
653                        nestedValue = ((Map) nestedValue).get(ImportCSVFileHelper.NESTED_MAPPING_VALUES);
654                    }
655                }
656                
657                // Get the value of the attribute for the current row
658                return row.get(nestedValue.toString());
659            }
660        });
661        
662        parentContent.ifPresent(content -> inputs.put(CreateContentFunction.PARENT_CONTEXT_VALUE, content.getId()));
663        
664        // Add all additional transient vars
665        inputs.putAll(additionalTransientVars);
666        
667        // CONTENTIO-253 To avoid issue with title starting with a non letter character, we prefix the name with the contentTypeId
668        String prefix = StringUtils.substringAfterLast(contentType.getId(), ".").toLowerCase();
669        String contentName = prefix + "-" + title;
670        
671        if (contentType.isMultilingual())
672        {
673            inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, language);
674            result = _contentWorkflowHelper.createContent(finalWorkflowName, createAction, contentName, Map.of(language, title), new String[] {contentType.getId()}, null, inputs);
675        }
676        else
677        {
678            result = _contentWorkflowHelper.createContent(finalWorkflowName, createAction, contentName, title, new String[] {contentType.getId()}, null, language, inputs);
679        }
680        
681        ModifiableWorkflowAwareContent content = (ModifiableWorkflowAwareContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
682        return new SynchronizeResult(true, Optional.of(content), true);
683    }
684    
685    private Map<String, Object> _getValues(Optional<ModifiableWorkflowAwareContent> content, Map<String, String> row, View view, Map<String, Object> mappingValues, int createAction, int editAction, String language, List<ViewItem> errors, ImportMode importMode, Map<String, Object> additionalTransientVars)
686    {
687        Map<String, Object> values = new HashMap<>();
688        
689        for (ViewItem viewItem : view.getViewItems())
690        {
691            try
692            {
693                Object value = _getValue(content, viewItem, mappingValues, row, createAction, editAction, language, errors, StringUtils.EMPTY, importMode, additionalTransientVars);
694                if (value != null)
695                {
696                    values.put(viewItem.getName(), value);
697                }
698            }
699            catch (Exception e)
700            {
701                errors.add(viewItem);
702                getLogger().error("Import from CSV file: error while trying to get values for item '{}'", viewItem.getName(), e);
703            }
704        }
705        
706        return values;
707    }
708    
709    private boolean _allAttributesFilled(Map<String, Object> mappingValues, List<String> attributeNames)
710    {
711        return mappingValues.entrySet()
712            .stream()
713            .filter(entry -> attributeNames.contains(entry.getKey()))
714            .map(Entry::getValue)
715            .allMatch(Objects::nonNull);
716    }
717    
718    /**
719     * Synchronize result
720     * @param isCreated If the content was created
721     * @param content The content
722     * @param hasKey has key
723     */
724    protected record SynchronizeResult(boolean isCreated, Optional<ModifiableWorkflowAwareContent> content, boolean hasKey) { /* empty */ }
725    
726}