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