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.File; 019import java.io.FileInputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.nio.charset.Charset; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.nio.file.Paths; 026import java.nio.file.StandardCopyOption; 027import java.util.ArrayList; 028import java.util.HashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.UUID; 032import java.util.function.Function; 033import java.util.stream.Collectors; 034 035import org.apache.avalon.framework.parameters.Parameters; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.cocoon.acting.ServiceableAction; 039import org.apache.cocoon.environment.ObjectModelHelper; 040import org.apache.cocoon.environment.Redirector; 041import org.apache.cocoon.environment.Request; 042import org.apache.cocoon.environment.SourceResolver; 043import org.apache.cocoon.servlet.multipart.Part; 044import org.apache.cocoon.servlet.multipart.PartOnDisk; 045import org.apache.cocoon.servlet.multipart.RejectedPart; 046import org.apache.commons.io.FileUtils; 047import org.apache.commons.io.FilenameUtils; 048import org.apache.commons.io.IOUtils; 049import org.apache.commons.lang3.StringUtils; 050import org.supercsv.prefs.CsvPreference; 051 052import org.ametys.cms.contenttype.ContentType; 053import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 054import org.ametys.cms.data.type.ModelItemTypeConstants; 055import org.ametys.core.cocoon.JSonReader; 056import org.ametys.plugins.workflow.support.WorkflowHelper; 057import org.ametys.runtime.i18n.I18nizableText; 058import org.ametys.runtime.model.Enumerator; 059import org.ametys.runtime.model.ModelItem; 060import org.ametys.runtime.servlet.RuntimeConfig; 061 062/** 063 * Import contents from an uploaded CSV file. 064 */ 065public class ImportCSVFileAction extends ServiceableAction 066{ 067 068 private static final String[] _ALLOWED_EXTENSIONS = new String[] {"txt", "csv"}; 069 070 private static final String CONTENTIO_STORAGE_DIRECTORY = "contentio/temp"; 071 072 private ContentTypeExtensionPoint _contentTypeEP; 073 074 private WorkflowHelper _workflowHelper; 075 076 private ImportCSVFileHelper _importCSVFileHelper; 077 078 private Enumerator _synchronizeModeEnumerator; 079 080 @Override 081 public void service(ServiceManager serviceManager) throws ServiceException 082 { 083 super.service(serviceManager); 084 _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 085 _workflowHelper = (WorkflowHelper) serviceManager.lookup(WorkflowHelper.ROLE); 086 _importCSVFileHelper = (ImportCSVFileHelper) manager.lookup(ImportCSVFileHelper.ROLE); 087 _synchronizeModeEnumerator = (Enumerator) serviceManager.lookup(SynchronizeModeEnumerator.ROLE); 088 } 089 090 @Override 091 public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception 092 { 093 Request request = ObjectModelHelper.getRequest(objectModel); 094 095 Map<String, Object> result = new HashMap<>(); 096 String contentTypeId = (String) request.get("contentType"); 097 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 098 Part part = (Part) request.get("file"); 099 if (part instanceof RejectedPart || part == null) 100 { 101 result.put("success", false); 102 result.put("error", "rejected"); 103 } 104 else 105 { 106 PartOnDisk uploadedFilePart = (PartOnDisk) part; 107 File uploadedFile = uploadedFilePart.getFile(); 108 109 String filename = uploadedFilePart.getFileName().toLowerCase(); 110 111 if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS)) 112 { 113 result.put("error", "invalid-extension"); 114 request.setAttribute(JSonReader.OBJECT_TO_READ, result); 115 return EMPTY_MAP; 116 } 117 118 try ( 119 InputStream fileInputStream = new FileInputStream(uploadedFile); 120 InputStream inputStream = IOUtils.buffer(fileInputStream); 121 ) 122 { 123 Charset charset = _importCSVFileHelper.detectCharset(inputStream); 124 result.put("charset", charset); 125 String[] headers = _importCSVFileHelper.extractHeaders(inputStream, _getCSVPreference(request), charset); 126 127 if (headers == null) 128 { 129 result.put("error", "no-header"); 130 request.setAttribute(JSonReader.OBJECT_TO_READ, result); 131 return EMPTY_MAP; 132 } 133 134 List<Map<String, Object>> mapping = _importCSVFileHelper.getMapping(contentType, headers); 135 result.put("mapping", mapping); 136 137 // Build a map to count how many attribute are there for each group 138 Map<String, Long> attributeCount = mapping.stream() 139 .map(map -> map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH)) 140 .map(String.class::cast) 141 .filter(StringUtils::isNotEmpty) 142 .map(attributePath -> { 143 String attributePrefix = ""; 144 int endIndex = attributePath.lastIndexOf("/"); 145 if (endIndex != -1) 146 { 147 attributePrefix = attributePath.substring(0, endIndex); 148 } 149 return attributePrefix; 150 }) 151 .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); 152 153 // If an attribute is the only for its group, it is the identifier 154 for (Map<String, Object> map : mapping) 155 { 156 String attributePath = (String) map.get(ImportCSVFileHelper.MAPPING_COLUMN_ATTRIBUTE_PATH); 157 String attributePrefix = StringUtils.EMPTY; 158 int endIndex = attributePath.lastIndexOf("/"); 159 if (endIndex != -1) 160 { 161 attributePrefix = attributePath.substring(0, endIndex); 162 } 163 164 boolean parentIsContent; 165 // If there is no prefix, it is an attribute of the content we want to import 166 if (attributePrefix.equals(StringUtils.EMPTY)) 167 { 168 parentIsContent = true; 169 } 170 // Otherwise, check the modelItem 171 else if (contentType.hasModelItem(attributePrefix)) 172 { 173 ModelItem modelItem = contentType.getModelItem(attributePrefix); 174 String modelTypeId = modelItem.getType().getId(); 175 parentIsContent = ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(modelTypeId); 176 } 177 else 178 { 179 parentIsContent = false; 180 } 181 182 // If an attribute is the only one of its level, and is part of a content, consider it as the identifier 183 if (attributeCount.getOrDefault(attributePrefix, 0L).equals(1L) && parentIsContent && StringUtils.isNotBlank(attributePath)) 184 { 185 map.put(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID, true); 186 } 187 } 188 189 String newPath = _copyFile(uploadedFile.toPath()); 190 result.put("path", newPath); 191 result.put("success", true); 192 result.put("workflows", _getWorkflows()); 193 result.put("defaultWorkflow", contentType.getDefaultWorkflowName().orElse(null)); 194 result.put("fileName", uploadedFile.getName()); 195 196 List<Map<String, Object>> synchronizeModes = ((Map<String, I18nizableText>) _synchronizeModeEnumerator.getTypedEntries()) 197 .entrySet() 198 .stream() 199 .map(entry -> Map.of("value", entry.getKey(), "label", entry.getValue())) 200 .toList(); 201 result.put("synchronizeModes", synchronizeModes); 202 } 203 } 204 205 request.setAttribute(JSonReader.OBJECT_TO_READ, result); 206 return EMPTY_MAP; 207 } 208 209 private String _copyFile(Path path) throws IOException 210 { 211 File contentIOStorageDir = FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), CONTENTIO_STORAGE_DIRECTORY); 212 213 if (!contentIOStorageDir.exists()) 214 { 215 if (!contentIOStorageDir.mkdirs()) 216 { 217 throw new IOException("Unable to create monitoring directory: " + contentIOStorageDir); 218 } 219 } 220 String id = UUID.randomUUID().toString() + ".csv"; 221 Path copy = Files.copy(path, Paths.get(contentIOStorageDir.getPath(), id), StandardCopyOption.REPLACE_EXISTING); 222 223 return copy.toString(); 224 225 } 226 227 private CsvPreference _getCSVPreference(Request request) 228 { 229 String escapingChar = (String) request.get("escaping-char"); 230 String separatingChar = (String) request.get("separating-char"); 231 char separating = separatingChar.charAt(0); 232 char escaping = escapingChar.charAt(0); 233 234 if (separating == escaping) 235 { 236 throw new IllegalArgumentException("Separating character can not be equals to escaping character"); 237 } 238 return new CsvPreference.Builder(escaping, separating, "\r\n").build(); 239 } 240 241 /** 242 * getWorkflows 243 * @return map of workflows 244 */ 245 private List<Map<String, Object>> _getWorkflows() 246 { 247 List<Map<String, Object>> workflows = new ArrayList<>(); 248 String[] workflowNames = _workflowHelper.getWorkflowNames(); 249 for (String workflowName : workflowNames) 250 { 251 Map<String, Object> workflowMap = new HashMap<>(); 252 workflowMap.put("value", workflowName); 253 workflowMap.put("label", _workflowHelper.getWorkflowLabel(workflowName)); 254 workflows.add(workflowMap); 255 } 256 return workflows; 257 } 258}