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.HashMap;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.Set;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030import org.apache.commons.lang3.ObjectUtils;
031import org.supercsv.io.ICsvListReader;
032import org.supercsv.util.Util;
033
034import org.ametys.cms.FilterNameHelper;
035import org.ametys.cms.contenttype.ContentAttributeDefinition;
036import org.ametys.cms.contenttype.ContentType;
037import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
038import org.ametys.cms.data.type.AbstractMultilingualStringElementType;
039import org.ametys.cms.data.type.impl.MultilingualStringRepositoryElementType;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.ContentQueryHelper;
042import org.ametys.cms.repository.ContentTypeExpression;
043import org.ametys.cms.repository.ModifiableDefaultContent;
044import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
045import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
046import org.ametys.cms.workflow.ContentWorkflowHelper;
047import org.ametys.cms.workflow.CreateContentFunction;
048import org.ametys.core.util.I18nUtils;
049import org.ametys.plugins.contentio.in.ContentImportException;
050import org.ametys.plugins.repository.AmetysObjectIterable;
051import org.ametys.plugins.repository.AmetysObjectResolver;
052import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
053import org.ametys.plugins.repository.data.holder.group.impl.ModifiableModelAwareRepeater;
054import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
055import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
056import org.ametys.plugins.repository.data.holder.values.SynchronizableValue.Mode;
057import org.ametys.plugins.repository.data.type.ModelItemTypeConstants;
058import org.ametys.plugins.repository.metadata.MultilingualString;
059import org.ametys.plugins.repository.query.expression.AndExpression;
060import org.ametys.plugins.repository.query.expression.Expression;
061import org.ametys.plugins.repository.query.expression.Expression.Operator;
062import org.ametys.plugins.repository.query.expression.MultilingualStringExpression;
063import org.ametys.plugins.repository.query.expression.StringExpression;
064import org.ametys.runtime.model.ElementDefinition;
065import org.ametys.runtime.model.ModelItem;
066import org.ametys.runtime.model.ModelViewItemGroup;
067import org.ametys.runtime.model.View;
068import org.ametys.runtime.model.ViewElement;
069import org.ametys.runtime.model.ViewElementAccessor;
070import org.ametys.runtime.model.ViewItem;
071import org.ametys.runtime.model.type.ElementType;
072import org.ametys.runtime.plugin.component.AbstractLogEnabled;
073
074import com.opensymphony.workflow.WorkflowException;
075
076/**
077 * Import contents from an uploaded CSV file.
078 */
079public class CSVImporter extends AbstractLogEnabled implements Component, Serviceable
080{
081    /** Avalon Role */
082    public static final String ROLE = CSVImporter.class.getName();
083
084    private ContentWorkflowHelper _contentWorkflowHelper;
085
086    private ContentTypeExtensionPoint _contentTypeEP;
087    
088    private AmetysObjectResolver _resolver;
089
090    private I18nUtils _i18nUtils;
091    
092    @Override
093    public void service(ServiceManager smanager) throws ServiceException
094    {
095        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
096        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
097        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
098        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
099    }
100
101    /**
102     * Extract contents from CSV file
103     * @param mapping mapping of content attributes and CSV file header
104     * @param view View of importing content
105     * @param contentType content type to import
106     * @param listReader mapReader to parse CSV file
107     * @param createAction creation action id
108     * @param editAction edition action id
109     * @param workflowName workflow name
110     * @param language language of created content.
111     * @return list of created contents
112     * @throws IOException IOException while reading CSV
113     */
114    public Map<String, Object> importContentsFromCSV(Map<String, Object> mapping, View view, ContentType contentType, ICsvListReader listReader, int createAction, int editAction, String workflowName, String language) throws IOException
115    {
116        List<String> contentIds = new ArrayList<>();
117        String[] columns = listReader.getHeader(true);
118        int nbErrors = 0;
119        int nbWarnings = 0;
120        List<String> row = null;
121        
122        while ((row = listReader.read()) != null)
123        {
124            try
125            {
126
127                if (listReader.length() != columns.length) 
128                {
129                    getLogger().error("Import from CSV file: content skipped because of invalid row: {}", row);
130                    nbErrors++;
131                    continue;
132                }
133
134                Map<String, String> rowMap = new HashMap<>();
135                Util.filterListToMap(rowMap, columns, row);
136                List<ViewItem> errors = new ArrayList<>();
137                Content content = _processContent(view, rowMap, contentType, mapping, createAction, editAction, workflowName, language, errors);
138                contentIds.add(content.getId());
139                if (!errors.isEmpty())
140                {
141                    nbWarnings++;
142                }
143            }
144            catch (Exception e)
145            {
146                nbErrors++;
147                getLogger().error("Import from CSV file: error importing the content on line {}", listReader.getLineNumber(), e);
148            }
149        }
150        
151        Map<String, Object> results = new HashMap<>();
152        results.put("contentIds", contentIds);
153        results.put("nbErrors", nbErrors);
154        results.put("nbWarnings", nbWarnings);
155        return results;
156    }
157    
158    private Content _processContent(View view, Map<String, String> row, ContentType contentType, Map<String, Object> mapping, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors) throws Exception
159    {
160        String attributeName = (String) mapping.get("id");
161        @SuppressWarnings("unchecked")
162        Map<String, Object> mappingValues = (Map<String, Object>) mapping.get("values");
163        Map<String, Object> values = new HashMap<>();
164        ModifiableWorkflowAwareContent content = _getOrCreateContent(mappingValues, row, contentType, workflowName, createAction, language, view.getModelViewItem(attributeName));
165        
166        for (ViewItem viewItem : view.getViewItems())
167        {
168            try
169            {
170                if (!_isId(viewItem, attributeName))
171                {
172                    Object value = _getValue(content, viewItem, mappingValues, row, createAction, editAction, workflowName, language, errors, "");
173                    if (value != null)
174                    {
175                        values.put(viewItem.getName(), value);
176                    }
177                }
178            }
179            catch (Exception e)
180            {
181                errors.add(viewItem);
182                getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
183            }
184        }
185        
186        _editContent(editAction, values, content);
187        
188        return content;
189    }
190
191    private void _editContent(int editAction, Map<String, Object> values, ModifiableWorkflowAwareContent content) throws WorkflowException
192    {
193        if (!values.isEmpty())
194        {
195            _contentWorkflowHelper.editContent(content, values, editAction);
196        }
197    }
198    
199    private boolean _isId(ViewItem viewItem, String attributeName)
200    {
201        if (viewItem instanceof ViewElement)
202        {
203            ViewElement viewElement = (ViewElement) viewItem;
204            ElementDefinition elementDefinition = viewElement.getDefinition();
205            if (!(elementDefinition instanceof ContentAttributeDefinition))
206            {
207                String elementName = elementDefinition.getName();
208                return elementName.equals(attributeName);
209            }
210        }
211        return false;
212    }
213    
214    private Object _getValue(ModifiableModelAwareDataHolder dataHolder, ViewItem viewItem, Map<String, Object> mapping, Map<String, String> row, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors, String prefix) throws Exception
215    {
216        if (viewItem instanceof ViewElement)
217        {
218            ViewElement viewElement = (ViewElement) viewItem;
219            ElementDefinition elementDefinition = viewElement.getDefinition();
220            if (elementDefinition instanceof ContentAttributeDefinition)
221            {
222                return _getContentAttributeDefinitionValues(viewItem, mapping, row, createAction, editAction, workflowName, language, viewElement, elementDefinition, errors);
223            }
224            else
225            {
226                return _getAttributeDefinitionValues(mapping, row, elementDefinition, language);
227            }
228        }
229        else if (viewItem instanceof ModelViewItemGroup)
230        {
231            ModelViewItemGroup modelViewItemGroup = (ModelViewItemGroup) viewItem;
232            List<ViewItem> children = modelViewItemGroup.getViewItems();
233            @SuppressWarnings("unchecked")
234            Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName());
235            @SuppressWarnings("unchecked")
236            Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get("values"));
237            if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelViewItemGroup.getDefinition().getType().getId()))
238            {
239                return _getRepeaterValues(dataHolder, modelViewItemGroup, row, createAction, editAction, workflowName, language, children, nestedMap, errors, prefix);
240            }
241            else
242            {
243                return _getCompositeValues(dataHolder, viewItem, row, createAction, editAction, workflowName, language, children, nestedMapValues, errors, prefix);
244            }
245        }
246        else
247        {
248            errors.add(viewItem);
249            throw new RuntimeException("Import from CSV file: unsupported type of ViewItem for view: " + viewItem.getName());
250        }
251    }
252
253    private Map<String, Object> _getCompositeValues(ModifiableModelAwareDataHolder dataHolder, ViewItem viewItem, Map<String, String> row, int createAction, int editAction,
254            String workflowName, String language, List<ViewItem> children, Map<String, Object> nestedMapValues, List<ViewItem> errors, String prefix)
255    {
256        Map<String, Object> compositeValues = new HashMap<>();
257        for (ViewItem child : children)
258        {
259            try
260            {
261                compositeValues.put(child.getName(), _getValue(dataHolder, child, nestedMapValues, row, createAction, editAction, workflowName, language, errors, prefix + viewItem.getName() + ModelItem.ITEM_PATH_SEPARATOR));
262            }
263            catch (Exception e)
264            {
265                errors.add(viewItem);
266                getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
267            }
268        }
269        return compositeValues;
270    }
271
272    private SynchronizableRepeater _getRepeaterValues(ModifiableModelAwareDataHolder dataHolder, ModelViewItemGroup viewItem, Map<String, String> row, int createAction, int editAction, String workflowName, String language,
273            List<ViewItem> children, Map<String, Object> nestedMap, List<ViewItem> errors, String prefix)
274    {
275        @SuppressWarnings("unchecked")
276        Map<String, Object> mappingValues = (Map<String, Object>) (nestedMap.get("values"));
277        String attributeName = (String) nestedMap.get("id");
278        String idValue = (String) mappingValues.get(attributeName);
279        Map<String, Object> repeaterValues = new HashMap<>();
280        List<Map<String, Object>> repeaterValuesList = new ArrayList<>();
281        List<Integer> indexList = new ArrayList<>();
282
283        if (ObjectUtils.allNotNull(idValue, row.get(idValue), dataHolder) && dataHolder.hasValue(prefix + viewItem.getName()))
284        {
285            ViewElement viewElement = (ViewElement) viewItem.getModelViewItem(attributeName);
286            ElementDefinition elementDefinition = viewElement.getDefinition();
287            ElementType elementType = elementDefinition.getType();
288            ModifiableModelAwareRepeater repeater = dataHolder.getValue(prefix + viewItem.getName());
289            repeater.getEntries().forEach(entry -> 
290            {
291                Object value = elementType.castValue(row.get(idValue));
292                if (value != null && value.equals(entry.getValue(attributeName)))
293                {
294                    indexList.add(entry.getPosition());
295                }
296            }); 
297        }
298        
299        Integer rowIndex = indexList.size() > 0 ? indexList.get(0) : 1;
300        
301        for (ViewItem child : children)
302        {
303            try
304            {
305                Object entryValues = _getValue(dataHolder, child, mappingValues, row, createAction, editAction, workflowName, language, errors, prefix + viewItem.getName() + "[" + rowIndex + "]" + ModelItem.ITEM_PATH_SEPARATOR);
306                repeaterValues.put(child.getName(), entryValues);
307            }
308            catch (Exception e)
309            {
310                errors.add(viewItem);
311                getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
312            }
313        }
314        repeaterValuesList.add(repeaterValues);
315        
316        
317        if (indexList.size() == 1)
318        {
319            return SynchronizableRepeater.replace(repeaterValuesList, indexList);
320        }
321        // If several rows match the id, only replace the first but add a warning
322        else if (indexList.size() > 1)
323        {
324            errors.add(viewItem);
325            return SynchronizableRepeater.replace(repeaterValuesList, List.of(indexList.get(0)));
326        }
327        else
328        {
329            return SynchronizableRepeater.appendOrRemove(repeaterValuesList, Set.of());
330        }
331        
332    }
333    
334    private Object _getContentAttributeDefinitionValues(ViewItem viewItem, Map<String, Object> mapping, Map<String, String> row,
335            int createAction, int editAction, String workflowName, String language, ViewElement viewElement, ElementDefinition elementDefinition, List<ViewItem> errors) throws Exception
336    {
337        ViewElementAccessor viewElementAccessor = (ViewElementAccessor) viewElement;
338        ContentAttributeDefinition contentAttributeDefinition = (ContentAttributeDefinition) elementDefinition;
339        String contentTypeId = contentAttributeDefinition.getContentTypeId();
340        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
341        @SuppressWarnings("unchecked")
342        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName());
343        @SuppressWarnings("unchecked")
344        Map<String, Object> mappingValues = (Map<String, Object>) (nestedMap.get("values"));
345        String attributeName = (String) nestedMap.get("id");
346        String idValue = (String) mappingValues.get(attributeName);
347        if (row.get(idValue) != null)
348        {
349            Map<String, Object> values = new HashMap<>();
350            ModifiableWorkflowAwareContent attachedContent = _getOrCreateContent(mappingValues, row, contentType, workflowName, createAction, language, viewElementAccessor.getModelViewItem(attributeName));
351            for (ViewItem nestedViewItem : viewElementAccessor.getViewItems())
352            {
353                try
354                {
355                    if (!_isId(nestedViewItem, attributeName))
356                    {
357                        Object value = _getValue(attachedContent, nestedViewItem, mappingValues, row, createAction, editAction, workflowName, language, errors, "");
358                        if (value != null)
359                        {
360                            values.put(nestedViewItem.getName(), value);
361                        }
362                    }
363                }
364                catch (Exception e)
365                {
366                    errors.add(viewItem);
367                    getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e);
368                }
369            } 
370
371            if (!values.isEmpty())
372            {
373                _editContent(editAction, values, attachedContent);
374            }
375            
376            //If content is multiple, we keep the old value list and we check if content was already inside, and add it otherwise
377            if (contentAttributeDefinition.isMultiple())
378            {
379                SynchronizableValue syncValue = new SynchronizableValue(List.of(attachedContent));
380                syncValue.setMode(Mode.APPEND);
381                return syncValue;
382            }
383            else
384            {
385                return attachedContent;
386            } 
387        }
388        return null;
389    }
390
391    private Object _getAttributeDefinitionValues(Map<String, Object> mapping, Map<String, String> row, ElementDefinition elementDefinition, String language)
392    {
393        ElementType elementType = elementDefinition.getType();
394        String elementName = elementDefinition.getName();
395        String elementColumn = (String) mapping.get(elementName);
396        String valueAsString = row.get(elementColumn);
397        Object value;
398        if (elementType instanceof AbstractMultilingualStringElementType && !valueAsString.contains(":"))
399        {
400            MultilingualString multilingualString = new MultilingualString();
401            multilingualString.add(new Locale(language), valueAsString);
402            value = multilingualString;
403        }
404        else
405        {
406            value = elementType.castValue(valueAsString);
407        }
408        if (elementDefinition.isMultiple())
409        {
410
411            SynchronizableValue syncValue = new SynchronizableValue(List.of(value));
412            syncValue.setMode(Mode.APPEND);
413            return syncValue;
414        }
415        else
416        {
417            return value;
418        }
419    }
420
421    private ModifiableWorkflowAwareContent _getOrCreateContent(Map<String, Object> mapping, Map<String, String> row, ContentType contentType, String workflowName, int createAction, String language, ViewItem viewItem) throws ContentImportException, WorkflowException
422    {
423        ViewElement viewElement = (ViewElement) viewItem;
424        ElementDefinition elementDefinition = viewElement.getDefinition();
425        String attributeName = elementDefinition.getName();
426        String attributePath = (String) mapping.get(attributeName);
427        String value = row.get(attributePath);
428
429        if (value == null)
430        {
431            throw new ContentImportException("Identifier field is empty for content " + contentType.getLabel());
432        }
433        
434        Expression idExpression;
435        if (elementDefinition.getType() instanceof MultilingualStringRepositoryElementType)
436        {
437            idExpression = new MultilingualStringExpression(attributeName, Operator.EQ, value, language);
438        }
439        else
440        {
441            idExpression = new StringExpression(attributeName, Operator.EQ, value);
442        }
443        
444        Expression expression = new AndExpression(
445                new ContentTypeExpression(Operator.EQ, contentType.getId()),
446                idExpression);
447
448        String xPathQuery = ContentQueryHelper.getContentXPathQuery(expression);
449        AmetysObjectIterable<ModifiableDefaultContent> matchingContents = _resolver.query(xPathQuery);
450        if (matchingContents.getSize() > 1)
451        {
452            throw new ContentImportException("More than one content found for type " + contentType.getLabel() + " with " 
453                    + attributeName + " as identifier and " + value + " as value");
454        }
455        else if (matchingContents.getSize() == 1)
456        {
457            return matchingContents.iterator().next();
458        }
459        
460        Map<String, Object> result;
461        String title;
462        if (mapping.containsKey("title"))
463        {
464            title = row.get(mapping.get("title"));
465        }
466        else
467        {
468            title = _i18nUtils.translate(contentType.getDefaultTitle(), language);
469        }
470        
471        String contentName = FilterNameHelper.filterName(title);
472        if (contentType.isMultilingual())
473        {
474
475            Map<String, Object> inputs = new HashMap<>();
476            inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, language);
477            result = _contentWorkflowHelper.createContent(workflowName, createAction, contentName, Map.of(language, title), new String[] {contentType.getId()}, null, null, null, inputs);
478        }
479        else
480        {
481            result = _contentWorkflowHelper.createContent(workflowName, createAction, contentName, title, new String[] {contentType.getId()}, null, language);
482        }
483        ModifiableWorkflowAwareContent content = (ModifiableWorkflowAwareContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
484        if (elementDefinition.getType() instanceof AbstractMultilingualStringElementType)
485        {
486            value = language + ":" + value;
487        }
488        content.setValue(attributeName, elementDefinition.getType().castValue(value));
489        return content;
490    } 
491}