001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.contentio.csv;
017
018import java.io.BufferedReader;
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.InputStreamReader;
022import java.nio.charset.Charset;
023import java.nio.file.Files;
024import java.nio.file.Paths;
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.commons.lang3.StringUtils;
036import org.supercsv.io.CsvListReader;
037import org.supercsv.io.ICsvListReader;
038import org.supercsv.prefs.CsvPreference;
039
040import org.ametys.cms.contenttype.ContentAttributeDefinition;
041import org.ametys.cms.contenttype.ContentType;
042import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
043import org.ametys.core.ui.Callable;
044import org.ametys.runtime.model.ElementDefinition;
045import org.ametys.runtime.model.ModelViewItemGroup;
046import org.ametys.runtime.model.View;
047import org.ametys.runtime.model.ViewElement;
048import org.ametys.runtime.model.ViewElementAccessor;
049import org.ametys.runtime.model.ViewItem;
050import org.ametys.runtime.model.exception.BadItemTypeException;
051import org.ametys.runtime.plugin.component.AbstractLogEnabled;
052
053/**
054 * Import contents from an uploaded CSV file.
055 */
056public class ImportCSVFileHelper extends AbstractLogEnabled implements Component, Serviceable
057{
058    /** Avalon Role */
059    public static final String ROLE = ImportCSVFileHelper.class.getName();
060    
061    private ContentTypeExtensionPoint _contentTypeEP;
062
063    private CSVImporter _csvImporter;
064
065    @Override
066    public void service(ServiceManager serviceManager) throws ServiceException
067    {
068        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
069        _csvImporter = (CSVImporter) serviceManager.lookup(CSVImporter.ROLE);
070    }
071    
072    /**
073     * Gets the configuration for creating/editing a collection of synchronizable contents.
074     * @param contentTypeId Content type id
075     * @param formValues map of form values
076     * @param mappingValues list of header and content attribute mapping
077     * @return A map containing information about what is needed to create/edit a collection of synchronizable contents
078     * @throws IOException IOException while reading CSV
079     */
080    @SuppressWarnings("unchecked")
081    @Callable
082    public Map<String, Object> validateConfiguration(String contentTypeId, Map formValues, List<Map<String, Object>> mappingValues) throws IOException
083    {
084        Map<String, Object> result = new HashMap<>();
085        Map<String, Object> mapping = new HashMap<>();
086        List<String> contentAttributes = new ArrayList<>();
087        mappingValues.forEach(column -> 
088        {
089            if (!StringUtils.isEmpty((String) column.get("attributePath")))
090            {
091                generateNestedMap(mapping, (String) column.get("attributePath"), (String) column.get("header"), (boolean) column.get("isId")); 
092                contentAttributes.add(((String) column.get("attributePath")).replace(".", "/"));
093            }
094        });
095        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
096        View view = null;
097        try 
098        {
099            view = View.of(Arrays.asList(contentType), contentAttributes.toArray(new String[0]));
100        }
101        catch (IllegalArgumentException | BadItemTypeException e)
102        {
103            getLogger().error("Error while creating view", e);
104            result.put("error", "bad-mapping");
105            return result;
106        }
107        if (!mapping.containsKey("id"))
108        {  
109            result.put("error", "missing-id");
110            return result;
111        } 
112        for (ViewItem viewItem : view.getViewItems())
113        {
114            if (!_checkViewItemContainer(viewItem, (Map<String, Object>) mapping.get("values"), result))
115            {
116                return result;
117            }
118        }
119
120        result.put("success", true);
121        return result;
122    }
123    /**
124     * Gets the configuration for creating/editing a collection of synchronizable contents.
125     * @param config get all CSV related parameters: path, separating/escaping char, charset and contentType
126     * @param formValues map of form values
127     * @param mappingValues list of header and content attribute mapping
128     * @return A map containing information about what is needed to create/edit a collection of synchronizable contents
129     * @throws IOException IOException while reading CSV
130     */
131    @Callable
132    public Map<String, Object> importContents(Map config, Map formValues, List<Map<String, Object>> mappingValues) throws IOException
133    {
134        Map<String, Object> result = new HashMap<>();
135        String path = (String) config.get("path");
136        String separatingChar = (String) config.get("separating-char");
137        String escapingChar = (String) config.get("escaping-char");
138        String contentTypeId = (String) config.get("contentType");
139        String encoding = (String) config.get("charset");
140        CsvPreference csvPreference = new CsvPreference.Builder(escapingChar.charAt(0), separatingChar.charAt(0), "\r\n").build();
141        Charset charset = Charset.forName(encoding);
142        String language = (String) formValues.get("language");
143        int createAction = Integer.valueOf((String) formValues.get("createAction"));
144        int editAction = Integer.valueOf((String) formValues.get("editAction"));
145        String workflowName = (String) formValues.get("workflow");
146        Map<String, Object> mapping = new HashMap<>();
147        List<String> contentAttributes = new ArrayList<>();
148        mappingValues.forEach(column -> 
149        {
150            if (!StringUtils.isEmpty((String) column.get("attributePath")))
151            {
152                generateNestedMap(mapping, (String) column.get("attributePath"), (String) column.get("header"), (boolean) column.get("isId")); 
153                contentAttributes.add(((String) column.get("attributePath")).replace(".", "/"));
154            }
155        });
156        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
157        View view = View.of(Arrays.asList(contentType), contentAttributes.toArray(new String[0]));
158
159        try
160        {
161            InputStream is = Files.newInputStream(Paths.get(path));
162            InputStreamReader isReader = new InputStreamReader(is, charset);
163            BufferedReader reader = new BufferedReader(isReader);
164            ICsvListReader csvMapReadernew = new CsvListReader(reader, csvPreference);
165            Map<String, Object> importResult = _csvImporter.importContentsFromCSV(mapping, view, contentType, csvMapReadernew, createAction, editAction, workflowName, language);
166
167            @SuppressWarnings("unchecked")
168            List<String> contentIds = (List<String>) importResult.get("contentIds");
169            if (contentIds.size() > 0)
170            {
171                result.put("importedCount", contentIds.size());
172                result.put("nbErrors", importResult.get("nbErrors"));
173                result.put("nbWarnings", importResult.get("nbWarnings"));
174            }
175            else
176            {
177                result.put("error", "no-import");
178            }
179        }
180        catch (Exception e)
181        {  
182            getLogger().error("Error while importing contents", e);
183            result.put("error", "error");
184        }
185        finally
186        {
187            deleteFile(path);
188            
189        }
190        return result;
191    }
192    
193    private boolean _checkViewItemContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result)
194    {
195        if (viewItem instanceof ViewElement)
196        {
197            ViewElement viewElement = (ViewElement) viewItem;
198            ElementDefinition elementDefinition = viewElement.getDefinition();
199            if (elementDefinition instanceof ContentAttributeDefinition)
200            {
201                ViewElementAccessor viewElementAccessor = (ViewElementAccessor) viewElement;
202                ContentAttributeDefinition contentAttributeDefinition = (ContentAttributeDefinition) elementDefinition;
203                return _checkViewItemContentContainer(viewElementAccessor, mapping, result, contentAttributeDefinition);
204            }
205            else
206            {
207                return true;
208            }
209        }
210        else if (viewItem instanceof ModelViewItemGroup)
211        {
212            return _checkViewItemGroupContainer(viewItem, mapping, result);
213        }
214        else
215        {
216            result.put("error", "unknown-type");
217            result.put("details", viewItem.getName());
218            return false;
219        }
220    }
221
222    @SuppressWarnings("unchecked")
223    private boolean _checkViewItemContentContainer(ViewElementAccessor viewElementAccessor, Map<String, Object> mapping, Map<String, Object> result, ContentAttributeDefinition contentAttributeDefinition)
224    {
225
226        boolean success = true;
227        String contentTypeId = contentAttributeDefinition.getContentTypeId();
228        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
229
230        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName());
231        if (!nestedMap.containsKey("id"))
232        {
233            result.put("error", "missing-id-content");
234            result.put("details", List.of(contentType.getLabel(), contentAttributeDefinition.getName()));
235            return false;
236        }
237        if (contentType.isAbstract())
238        {
239            result.put("error", "abstract-content");
240            result.put("details", List.of(contentType.getLabel(), contentAttributeDefinition.getName()));
241            return false;
242        }
243        Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get("values"));
244        for (ViewItem nestedViewItem : viewElementAccessor.getViewItems())
245        {
246            success = success || _checkViewItemContainer(nestedViewItem, nestedMapValues, result);
247        }
248        return success;
249    }
250    
251    @SuppressWarnings("unchecked")
252    private boolean _checkViewItemGroupContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result)
253    {
254        boolean success = true;
255        ModelViewItemGroup modelViewItemGroup = (ModelViewItemGroup) viewItem;
256        List<ViewItem> elementDefinition = modelViewItemGroup.getViewItems();
257        Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName());
258        
259        Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get("values"));
260                    
261        for (ViewItem nestedViewItem : elementDefinition)
262        {
263            success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result);
264        }
265        return success;
266    }
267
268    @SuppressWarnings("unchecked")
269    private static Map<String, Object> generateNestedMap(Map<String, Object> map, String attributePath, String column, boolean isId) 
270    {
271        int dotIndex = attributePath.indexOf('.');
272
273        if (!map.containsKey("values"))
274        {
275            map.put("values", new HashMap<String, Object>());
276        }
277        
278        if (dotIndex == -1)
279        {
280            if (Boolean.valueOf(isId))
281            {
282                map.put("id", attributePath);
283            }
284            ((Map<String, Object>) map.get("values")).put(attributePath, column);
285        }
286        else
287        {
288            String prefix = attributePath.split("\\.")[0];
289            String subAttribute = attributePath.substring(dotIndex + 1);
290            
291            if (!((Map<String, Object>) map.get("values")).containsKey(prefix))
292            {
293                ((Map<String, Object>) map.get("values")).put(prefix, new HashMap<String, Object>());
294            }
295            
296            generateNestedMap((Map<String, Object>) ((Map<String, Object>) map.get("values")).get(prefix), subAttribute, column, isId);
297        }
298        return map;
299    }
300
301    /**
302     * Delete the file related to the given path
303     * @param path path of the file
304     * @throws IOException if an error occurs while deleting the file
305     */
306    @Callable
307    public void deleteFile(String path) throws IOException 
308    {
309        Files.delete(Paths.get(path));
310    }
311    
312}