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