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.ByteArrayInputStream; 020import java.io.File; 021import java.io.FileInputStream; 022import java.io.IOException; 023import java.io.InputStream; 024import java.io.InputStreamReader; 025import java.nio.charset.Charset; 026import java.util.ArrayList; 027import java.util.Arrays; 028import java.util.HashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.Objects; 032import java.util.Optional; 033import java.util.function.Predicate; 034import java.util.stream.Collectors; 035 036import org.apache.avalon.framework.activity.Initializable; 037import org.apache.avalon.framework.component.Component; 038import org.apache.avalon.framework.service.ServiceException; 039import org.apache.avalon.framework.service.ServiceManager; 040import org.apache.avalon.framework.service.Serviceable; 041import org.apache.commons.io.FileUtils; 042import org.apache.commons.io.IOUtils; 043import org.apache.commons.lang3.StringUtils; 044import org.apache.tika.detect.DefaultEncodingDetector; 045import org.apache.tika.metadata.Metadata; 046import org.supercsv.io.CsvListReader; 047import org.supercsv.io.CsvMapReader; 048import org.supercsv.io.ICsvListReader; 049import org.supercsv.io.ICsvMapReader; 050import org.supercsv.prefs.CsvPreference; 051 052import org.ametys.cms.contenttype.ContentAttributeDefinition; 053import org.ametys.cms.contenttype.ContentType; 054import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 055import org.ametys.cms.contenttype.ContentTypesHelper; 056import org.ametys.cms.repository.Content; 057import org.ametys.core.ui.Callable; 058import org.ametys.core.user.CurrentUserProvider; 059import org.ametys.core.user.User; 060import org.ametys.core.user.UserIdentity; 061import org.ametys.core.user.UserManager; 062import org.ametys.core.util.I18nUtils; 063import org.ametys.core.util.language.UserLanguagesManager; 064import org.ametys.core.util.mail.SendMailHelper; 065import org.ametys.core.util.mail.SendMailHelper.MailBuilder; 066import org.ametys.plugins.contentio.csv.SynchronizeModeEnumerator.ImportMode; 067import org.ametys.plugins.repository.AmetysObjectResolver; 068import org.ametys.plugins.repository.model.RepeaterDefinition; 069import org.ametys.runtime.config.Config; 070import org.ametys.runtime.i18n.I18nizableText; 071import org.ametys.runtime.i18n.I18nizableTextParameter; 072import org.ametys.runtime.model.ElementDefinition; 073import org.ametys.runtime.model.ModelHelper; 074import org.ametys.runtime.model.ModelItem; 075import org.ametys.runtime.model.ModelItemGroup; 076import org.ametys.runtime.model.ModelViewItemGroup; 077import org.ametys.runtime.model.View; 078import org.ametys.runtime.model.ViewElement; 079import org.ametys.runtime.model.ViewElementAccessor; 080import org.ametys.runtime.model.ViewItem; 081import org.ametys.runtime.model.exception.BadItemTypeException; 082import org.ametys.runtime.plugin.component.AbstractLogEnabled; 083import org.ametys.runtime.servlet.RuntimeConfig; 084 085import jakarta.mail.MessagingException; 086 087/** 088 * Import contents from an uploaded CSV file. 089 */ 090public class ImportCSVFileHelper extends AbstractLogEnabled implements Component, Serviceable, Initializable 091{ 092 /** Avalon Role */ 093 public static final String ROLE = ImportCSVFileHelper.class.getName(); 094 095 /** Key in config containing the parent id */ 096 public static final String CONFIG_PARENT_ID = "parentId"; 097 /** Result key containing updated content's ids */ 098 public static final String RESULT_IMPORTED_COUNT = "importedCount"; 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 /** Result key containing a short an explanation for a general error */ 104 public static final String RESULT_ERROR = "error"; 105 /** Result key containing the filename */ 106 public static final String RESULT_FILENAME = "fileName"; 107 /** Column name of attribute path in CSV mapping */ 108 public static final String MAPPING_COLUMN_ATTRIBUTE_PATH = "attributePath"; 109 /** Column name of header in CSV mapping */ 110 public static final String MAPPING_COLUMN_HEADER = "header"; 111 /** Column name to identify if column is an ID in CSV mapping */ 112 public static final String MAPPING_COLUMN_IS_ID = "isId"; 113 /** Key in nested mapping to define the ID columns */ 114 public static final String NESTED_MAPPING_ID = "id"; 115 /** Key in nested mapping to define the values columns */ 116 public static final String NESTED_MAPPING_VALUES = "values"; 117 118 /** The Ametys object resolver */ 119 protected AmetysObjectResolver _resolver; 120 /** The CSV Importer */ 121 protected CSVImporter _csvImporter; 122 /** The content type extension point */ 123 protected ContentTypeExtensionPoint _contentTypeEP; 124 125 private CurrentUserProvider _currentUserProvider; 126 private UserManager _userManager; 127 private UserLanguagesManager _userLanguagesManager; 128 129 private ContentTypesHelper _contentTypesHelper; 130 private I18nUtils _i18nUtils; 131 private String _sysadminMail; 132 133 @Override 134 public void service(ServiceManager serviceManager) throws ServiceException 135 { 136 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 137 _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE); 138 _userLanguagesManager = (UserLanguagesManager) serviceManager.lookup(UserLanguagesManager.ROLE); 139 140 _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 141 _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE); 142 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 143 144 _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE); 145 146 _csvImporter = (CSVImporter) serviceManager.lookup(CSVImporter.ROLE); 147 } 148 149 public void initialize() throws Exception 150 { 151 _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto"); 152 } 153 154 /** 155 * Retrieves the values for import CSV parameters 156 * @param contentTypeId the configured content type identifier 157 * @return the values for import CSV parameters 158 * @throws Exception if an error occurs. 159 */ 160 @Callable(rights = Callable.NO_CHECK_REQUIRED) // only retrieve content type definition 161 public Map<String, Object> getImportCSVParametersValues(String contentTypeId) throws Exception 162 { 163 Map<String, Object> values = new HashMap<>(); 164 165 // Content types 166 List<String> contentTypeIds = StringUtils.isEmpty(contentTypeId) ? List.of() : List.of(contentTypeId); 167 Map<String, Object> filteredContentTypes = _contentTypesHelper.getContentTypesList(contentTypeIds, true, true, true, false, false); 168 values.put("availableContentTypes", filteredContentTypes.get("contentTypes")); 169 170 // Recipient - get current user email 171 String currentUserEmail = null; 172 UserIdentity currentUser = _currentUserProvider.getUser(); 173 174 String login = currentUser.getLogin(); 175 if (StringUtils.isNotBlank(login)) 176 { 177 String userPopulationId = currentUser.getPopulationId(); 178 User user = _userManager.getUser(userPopulationId, login); 179 currentUserEmail = user.getEmail(); 180 } 181 values.put("defaultRecipient", currentUserEmail); 182 183 return values; 184 } 185 186 /** 187 * Gets the configuration for creating/editing a collection of synchronizable contents. 188 * @param contentTypeId Content type id 189 * @param formValues map of form values 190 * @param mappingValues list of header and content attribute mapping 191 * @return A map containing information about what is needed to create/edit a collection of synchronizable contents 192 * @throws IOException IOException while reading CSV 193 */ 194 @SuppressWarnings("unchecked") 195 @Callable(rights = Callable.NO_CHECK_REQUIRED) // Only checks against content type definition 196 public Map<String, Object> validateConfiguration(String contentTypeId, Map formValues, List<Map<String, Object>> mappingValues) throws IOException 197 { 198 Map<String, Object> result = new HashMap<>(); 199 200 List<Map<String, Object>> filteredMappingValues = _filterMapping(mappingValues); 201 202 List<String> contentAttributes = filteredMappingValues.stream() 203 .map(column -> column.get(MAPPING_COLUMN_ATTRIBUTE_PATH)) 204 .map(String.class::cast) 205 .collect(Collectors.toList()); 206 207 Map<String, Object> mapping = new HashMap<>(); 208 filteredMappingValues.forEach(column -> generateNestedMapping(mapping, column)); 209 210 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 211 result.put("contentTypeName", contentType.getLabel()); 212 View view = null; 213 for (String itemPath : contentAttributes) 214 { 215 if (!ModelHelper.hasModelItem(itemPath, List.of(contentType))) 216 { 217 result.put("error", "bad-mapping"); 218 result.put("details", List.of(itemPath, contentTypeId)); 219 return result; 220 } 221 } 222 223 try 224 { 225 view = View.of(contentType, contentAttributes.toArray(new String[contentAttributes.size()])); 226 } 227 catch (IllegalArgumentException | BadItemTypeException e) 228 { 229 getLogger().error("Error while creating view", e); 230 result.put("error", "cant-create-view"); 231 result.put("details", contentTypeId); 232 return result; 233 } 234 235 if (!mapping.containsKey(NESTED_MAPPING_ID)) 236 { 237 result.put("error", "missing-id"); 238 return result; 239 } 240 241 for (ViewItem viewItem : view.getViewItems()) 242 { 243 if (!_checkViewItemContainer(viewItem, (Map<String, Object>) mapping.get(NESTED_MAPPING_VALUES), result)) 244 { 245 return result; 246 } 247 } 248 result.put("success", true); 249 return result; 250 } 251 /** 252 * Gets the configuration for creating/editing a collection of synchronizable contents. 253 * @param config get all CSV related parameters: path, separating/escaping char, charset and contentType 254 * @param formValues map of form values 255 * @param mappingValues list of header and content attribute mapping 256 * @return A map containing information about what is needed to create/edit a collection of synchronizable contents 257 * @throws IOException IOException while reading CSV 258 */ 259 @Callable(rights = "Plugins_ContentIO_ImportFile", context = "/cms") 260 public Map<String, Object> importContents(Map<String, Object> config, Map<String, Object> formValues, List<Map<String, Object>> mappingValues) throws IOException 261 { 262 Map<String, Object> result = new HashMap<>(); 263 result.put(RESULT_FILENAME, config.get("fileName")); 264 265 String importId = (String) config.get("importId"); 266 File importFile = _getTempFile(importId); 267 String contentTypeId = (String) config.get("contentType"); 268 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 269 270 // CSV configuration 271 CsvPreference csvPreference = new CsvPreference.Builder( 272 config.getOrDefault("escaping-char", '"').toString().charAt(0), 273 config.getOrDefault("separating-char", ';').toString().charAt(0), 274 "\r\n" 275 ) 276 .build(); 277 278 String encoding = (String) config.get("charset"); 279 Charset charset = Charset.forName(encoding); 280 281 String language = (String) formValues.get("language"); 282 int createAction = Integer.valueOf((String) formValues.getOrDefault("createAction", "1")); 283 int editAction = Integer.valueOf((String) formValues.getOrDefault("editAction", "2")); 284 String workflowName = (String) formValues.get("workflow"); 285 286 ImportMode importMode = Optional.ofNullable((String) formValues.get("importMode")).filter(StringUtils::isNotEmpty).map(ImportMode::valueOf).orElse(ImportMode.CREATE_AND_UPDATE); 287 Optional<String> recipient = Optional.ofNullable(config.get("recipient")) 288 .filter(String.class::isInstance) 289 .map(String.class::cast) 290 .filter(StringUtils::isNotBlank); 291 292 Optional< ? extends Content> parentContent = Optional.empty(); 293 String parentId = (String) config.get(CONFIG_PARENT_ID); 294 if (parentId != null) 295 { 296 Content parent = _resolver.resolveById(parentId); 297 if (parent != null) 298 { 299 parentContent = Optional.of(parent); 300 } 301 } 302 303 @SuppressWarnings("unchecked") 304 Map<String, Object> additionalTransientVars = (Map<String, Object>) config.getOrDefault("additionalTransientVars", new HashMap<>()); 305 306 List<Map<String, Object>> filteredMappingValues = _filterMapping(mappingValues); 307 308 try ( 309 InputStream fileInputStream = new FileInputStream(importFile); 310 InputStream inputStream = IOUtils.buffer(fileInputStream); 311 ) 312 { 313 result.putAll(_importContents(inputStream, csvPreference, charset, contentType, workflowName, language, createAction, editAction, filteredMappingValues, importMode, parentContent, additionalTransientVars)); 314 315 // If an exception occured and the recipient is empty, transfer the error mail to the configured sysadmin 316 if ("error".equals(result.get(RESULT_ERROR)) && recipient.isEmpty()) 317 { 318 recipient = Optional.ofNullable(_sysadminMail) 319 .filter(StringUtils::isNotBlank); 320 } 321 322 if (recipient.isPresent()) 323 { 324 _sendMail(result, recipient.get()); 325 } 326 } 327 finally 328 { 329 FileUtils.deleteQuietly(importFile); 330 } 331 332 return result; 333 } 334 335 /** 336 * Import contents from path, content type and language. 337 * CSV Excel north Europe preferences are used, charset is detected on the file, workflow is detected on content type, default creation (1) and 338 * edition (2) actions are used, values are automatically mapped from the header. Headers should contains a star (*) to detect identifier columns. 339 * @param inputStream The (buffered) input stream to the CSV resource 340 * @param contentType The content type 341 * @param language The language 342 * @param importMode The import mode 343 * @return the result of the CSV import 344 * @throws IOException if an error occurs 345 */ 346 public Map<String, Object> importContents(InputStream inputStream, ContentType contentType, String language, ImportMode importMode) throws IOException 347 { 348 CsvPreference csvPreference = CsvPreference.EXCEL_NORTH_EUROPE_PREFERENCE; 349 350 // Detect the charset of the current file 351 Charset charset = detectCharset(inputStream); 352 353 // Copy the first 8192 bytes of the initial input stream to another input stream (to get headers) 354 inputStream.mark(8192); 355 try (InputStream headerStream = new ByteArrayInputStream(inputStream.readNBytes(8192))) 356 { 357 inputStream.reset(); 358 359 // Get headers 360 String[] headers = extractHeaders(headerStream, csvPreference, charset); 361 362 // Get the mapping of the values 363 List<Map<String, Object>> mappingValues = getMapping(contentType, headers); 364 365 // Get the default workflow associated to the content type 366 String workflowName = contentType.getDefaultWorkflowName().orElseThrow(() -> new IllegalArgumentException("The workflow can't be defined.")); 367 368 // Import contents (last use of the input stream) 369 return _importContents(inputStream, csvPreference, charset, contentType, workflowName, language, 1, 2, mappingValues, importMode); 370 } 371 } 372 373 private Map<String, Object> _importContents(InputStream inputStream, CsvPreference csvPreference, Charset charset, ContentType contentType, String workflowName, String language, int createAction, int editAction, List<Map<String, Object>> mappingValues, ImportMode importMode) 374 { 375 return _importContents(inputStream, csvPreference, charset, contentType, workflowName, language, createAction, editAction, mappingValues, importMode, Optional.empty(), Map.of()); 376 } 377 378 private Map<String, Object> _importContents(InputStream inputStream, CsvPreference csvPreference, Charset charset, ContentType contentType, String workflowName, String language, int createAction, int editAction, List<Map<String, Object>> mappingValues, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) 379 { 380 Map<String, Object> result = new HashMap<>(); 381 382 List<String> contentAttributes = mappingValues.stream() 383 .map(column -> column.get(MAPPING_COLUMN_ATTRIBUTE_PATH)) 384 .map(String.class::cast) 385 .collect(Collectors.toList()); 386 387 Map<String, Object> mapping = new HashMap<>(); 388 mappingValues.forEach(column -> generateNestedMapping(mapping, column)); 389 390 View view = View.of(contentType, contentAttributes.toArray(new String[contentAttributes.size()])); 391 392 try ( 393 InputStreamReader inputStreamReader = new InputStreamReader(inputStream, charset); 394 BufferedReader reader = new BufferedReader(inputStreamReader); 395 ICsvListReader csvMapReadernew = new CsvListReader(reader, csvPreference) 396 ) 397 { 398 Map<String, Object> importResult = _csvImporter.importContentsFromCSV(mapping, view, contentType, csvMapReadernew, createAction, editAction, workflowName, language, importMode, parentContent, additionalTransientVars); 399 400 @SuppressWarnings("unchecked") 401 List<String> contentIds = (List<String>) importResult.get(CSVImporter.RESULT_CONTENT_IDS); 402 403 // fill RESULT_ERROR only if the content list is empty and if there are errors, as an empty file or no import in CREATE_ONLY and UPDATE_ONLY mode should not be considered as errors 404 if (contentIds.size() == 0 && (int) importResult.get(CSVImporter.RESULT_NB_ERRORS) > 0) 405 { 406 result.put(RESULT_ERROR, "no-import"); 407 } 408 else 409 { 410 result.put(RESULT_IMPORTED_COUNT, contentIds.size()); 411 result.put(RESULT_NB_ERRORS, importResult.get(CSVImporter.RESULT_NB_ERRORS)); 412 result.put(RESULT_NB_WARNINGS, importResult.get(CSVImporter.RESULT_NB_WARNINGS)); 413 } 414 } 415 catch (Exception e) 416 { 417 getLogger().error("Error while importing contents", e); 418 result.put(RESULT_ERROR, "error"); 419 } 420 421 return result; 422 } 423 424 private boolean _checkViewItemContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result) 425 { 426 if (viewItem instanceof ViewElement) 427 { 428 ViewElement viewElement = (ViewElement) viewItem; 429 ElementDefinition elementDefinition = viewElement.getDefinition(); 430 if (elementDefinition instanceof ContentAttributeDefinition) 431 { 432 ViewElementAccessor viewElementAccessor = (ViewElementAccessor) viewElement; 433 ContentAttributeDefinition contentAttributeDefinition = (ContentAttributeDefinition) elementDefinition; 434 return _checkViewItemContentContainer(viewElementAccessor, mapping, result, contentAttributeDefinition); 435 } 436 else 437 { 438 return true; 439 } 440 } 441 else if (viewItem instanceof ModelViewItemGroup) 442 { 443 return _checkViewItemGroupContainer(viewItem, mapping, result); 444 } 445 else 446 { 447 result.put("error", "unknown-type"); 448 result.put("details", viewItem.getName()); 449 return false; 450 } 451 } 452 453 @SuppressWarnings("unchecked") 454 private boolean _checkViewItemContentContainer(ViewElementAccessor viewElementAccessor, Map<String, Object> mapping, Map<String, Object> result, ContentAttributeDefinition contentAttributeDefinition) 455 { 456 457 boolean success = true; 458 String contentTypeId = contentAttributeDefinition.getContentTypeId(); 459 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 460 if (mapping.get(viewElementAccessor.getName()) instanceof String) 461 { 462 // Path matches a content instead of a contents attribute 463 result.put("error", "string-as-container"); 464 result.put("details", mapping.get(viewElementAccessor.getName())); 465 return false; 466 } 467 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName()); 468 if (!nestedMap.containsKey(NESTED_MAPPING_ID) || ((List<String>) nestedMap.get(NESTED_MAPPING_ID)).isEmpty()) 469 { 470 // No identifier for the content 471 result.put("error", "missing-id-content"); 472 result.put("details", List.of(contentType.getLabel(), contentAttributeDefinition.getName())); 473 return false; 474 } 475 if (viewElementAccessor.getDefinition().isMultiple()) 476 { 477 Optional<RepeaterDefinition> repeater = _getRepeaterAncestorIfExists(viewElementAccessor.getDefinition()); 478 if (repeater.isPresent()) 479 { 480 // Content is multiple inside a repeater, we do not handle this as we can't discriminate content id from repeater id 481 result.put("error", "multiple-content-inside-repeater"); 482 result.put("details", List.of(contentType.getLabel(), repeater.get().getLabel())); 483 return false; 484 } 485 } 486 Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(NESTED_MAPPING_VALUES)); 487 for (ViewItem nestedViewItem : viewElementAccessor.getViewItems()) 488 { 489 success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result); 490 } 491 return success; 492 } 493 494 /** 495 * Get the repeater ancestor of the given model item if exists, searching parent's parent recursively if needed. 496 * @param modelItem the model item 497 * @return the repeater ancestor if exists 498 */ 499 private Optional<RepeaterDefinition> _getRepeaterAncestorIfExists(ModelItem modelItem) 500 { 501 ModelItemGroup parent = modelItem.getParent(); 502 // There is no parent anymore: we stop there 503 if (parent == null) 504 { 505 return Optional.empty(); 506 } 507 // Parent is a repeater: we stop there and return the repeater 508 if (parent instanceof RepeaterDefinition repeaterParent) 509 { 510 return Optional.of(repeaterParent); 511 } 512 // The parent is not a repeater, but his parent may be one. Check if the parent have a repeater as parent. 513 else 514 { 515 return _getRepeaterAncestorIfExists(parent); 516 } 517 } 518 519 @SuppressWarnings("unchecked") 520 private boolean _checkViewItemGroupContainer(ViewItem viewItem, Map<String, Object> mapping, Map<String, Object> result) 521 { 522 boolean success = true; 523 ModelViewItemGroup modelViewItemGroup = (ModelViewItemGroup) viewItem; 524 List<ViewItem> elementDefinition = modelViewItemGroup.getViewItems(); 525 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName()); 526 527 Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(NESTED_MAPPING_VALUES)); 528 529 for (ViewItem nestedViewItem : elementDefinition) 530 { 531 success = success && _checkViewItemContainer(nestedViewItem, nestedMapValues, result); 532 } 533 return success; 534 } 535 536 private static Map<String, Object> generateNestedMapping(Map<String, Object> mapping, Map<String, Object> column) 537 { 538 return generateNestedMapping(mapping, (String) column.get(MAPPING_COLUMN_ATTRIBUTE_PATH), (String) column.get(ImportCSVFileHelper.MAPPING_COLUMN_HEADER), (boolean) column.get(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID)); 539 } 540 541 @SuppressWarnings("unchecked") 542 private static Map<String, Object> generateNestedMapping(Map<String, Object> mapping, String attributePath, String column, boolean isId) 543 { 544 Map<String, Object> values = (Map<String, Object>) mapping.computeIfAbsent(NESTED_MAPPING_VALUES, __ -> new HashMap<>()); 545 546 int separatorIndex = attributePath.indexOf('/'); 547 if (separatorIndex == -1) 548 { 549 if (isId) 550 { 551 List<String> ids = (List<String>) mapping.computeIfAbsent(NESTED_MAPPING_ID, __ -> new ArrayList<>()); 552 ids.add(attributePath); 553 } 554 values.put(attributePath, column); 555 } 556 else 557 { 558 Map<String, Object> subValuesMapping = (Map<String, Object>) values.computeIfAbsent(attributePath.substring(0, separatorIndex), __ -> new HashMap<>()); 559 generateNestedMapping(subValuesMapping, attributePath.substring(separatorIndex + 1), column, isId); 560 } 561 562 mapping.put(NESTED_MAPPING_VALUES, values); 563 return mapping; 564 } 565 566 private void _sendMail(Map<String, Object> importResult, String recipient) 567 { 568 I18nizableText subject = _getMailSubject(importResult); 569 I18nizableText body = _getMailBody(importResult); 570 571 try 572 { 573 String language = _userLanguagesManager.getDefaultLanguage(); 574 MailBuilder mailBuilder = SendMailHelper.newMail() 575 .withSubject(_i18nUtils.translate(subject, language)) 576 .withRecipient(recipient) 577 .withTextBody(_i18nUtils.translate(body, language)); 578 579 mailBuilder.sendMail(); 580 } 581 catch (MessagingException | IOException e) 582 { 583 if (getLogger().isWarnEnabled()) 584 { 585 getLogger().warn("Unable to send the e-mail '" + subject + "' to '" + recipient + "'", e); 586 } 587 } 588 catch (Exception e) 589 { 590 getLogger().error("An unknown error has occured while sending the mail.", e); 591 } 592 } 593 594 private I18nizableText _getMailSubject(Map<String, Object> importResult) 595 { 596 Map<String, I18nizableTextParameter> i18nParams = Map.of("fileName", new I18nizableText((String) importResult.get(RESULT_FILENAME))); 597 if (importResult.containsKey(ImportCSVFileHelper.RESULT_ERROR)) 598 { 599 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_SUBJECT", i18nParams); 600 } 601 else 602 { 603 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_SUBJECT", i18nParams); 604 } 605 } 606 607 private I18nizableText _getMailBody(Map<String, Object> importResult) 608 { 609 Map<String, I18nizableTextParameter> i18nParams = new HashMap<>(); 610 i18nParams.put("fileName", new I18nizableText((String) importResult.get(RESULT_FILENAME))); 611 612 if (importResult.containsKey(ImportCSVFileHelper.RESULT_ERROR)) 613 { 614 if ("no-import".equals(importResult.get(ImportCSVFileHelper.RESULT_ERROR))) 615 { 616 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_NO_IMPORT", i18nParams); 617 } 618 else 619 { 620 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_ERROR_BODY_TEXT", i18nParams); 621 } 622 } 623 else 624 { 625 int nbErrors = (int) importResult.get(RESULT_NB_ERRORS); 626 int nbWarnings = (int) importResult.get(RESULT_NB_WARNINGS); 627 i18nParams.put("importedCount", new I18nizableText(String.valueOf(importResult.get(RESULT_IMPORTED_COUNT)))); 628 i18nParams.put("nbErrors", new I18nizableText(String.valueOf(nbErrors))); 629 i18nParams.put("nbWarnings", new I18nizableText(String.valueOf(nbWarnings))); 630 631 if (nbErrors == 0 && nbWarnings == 0) 632 { 633 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_SUCCESS_BODY", i18nParams); 634 } 635 else if (nbWarnings == 0) 636 { 637 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS", i18nParams); 638 } 639 else if (nbErrors == 0) 640 { 641 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_WARNINGS", i18nParams); 642 } 643 else 644 { 645 return new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_CONTENT_IMPORT_MAIL_PARTIAL_SUCCESS_BODY_ERRORS_AND_WARNINGS", i18nParams); 646 } 647 } 648 } 649 650 /** 651 * Indicate that an import is cancel during configuration 652 * @param importId the import id 653 */ 654 @Callable(rights = "Plugins_ContentIO_ImportFile", context = "/cms") 655 public void cancelImport(String importId) 656 { 657 File importFile = _getTempFile(importId); 658 FileUtils.deleteQuietly(importFile); 659 } 660 661 private File _getTempFile(String importId) 662 { 663 File importFile = FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), ImportCSVFile.CONTENTIO_STORAGE_DIRECTORY, importId + ".csv"); 664 return importFile; 665 } 666 667 /** 668 * Detect the charset of a given resource. 669 * @param inputStream The input stream to the resource, need to support {@link InputStream#mark(int)} and {@link InputStream#reset()}. 670 * @return the charset 671 * @throws IOException if an error occurs 672 */ 673 public Charset detectCharset(InputStream inputStream) throws IOException 674 { 675 assert inputStream.markSupported(); 676 DefaultEncodingDetector defaultEncodingDetector = new DefaultEncodingDetector(); 677 return defaultEncodingDetector.detect(inputStream, new Metadata()); 678 } 679 680 /** 681 * Extract headers from the CSV resource. 682 * @param inputStream The input stream to the resource 683 * @param csvPreference The CSV preference (separators) 684 * @param charset The charset of the resource 685 * @return the list of headers in file 686 * @throws IOException if an error occurs 687 */ 688 public String[] extractHeaders(InputStream inputStream, CsvPreference csvPreference, Charset charset) throws IOException 689 { 690 try ( 691 InputStreamReader inputStreamReader = new InputStreamReader(inputStream, charset); 692 BufferedReader reader = new BufferedReader(inputStreamReader); 693 ICsvMapReader mapReader = new CsvMapReader(reader, csvPreference); 694 ) 695 { 696 String[] headers = mapReader.getHeader(true); 697 return headers; 698 } 699 } 700 701 /** 702 * Get the mapping from the headers and corresponding to the content type. 703 * @param contentType The content type 704 * @param headers The CSV headers 705 * @return the list of columns descriptions. An item of the list is one column, each column is defined by a header "header" (text defined into the CSV header), an 706 * attribute path "attributePath" (real path extracted from the header, empty if the model item does not exist) and an identifier flag "isId" (<code>true</code> 707 * if the header ends with a star "*"). 708 */ 709 public List<Map<String, Object>> getMapping(ContentType contentType, String[] headers) 710 { 711 return Arrays.asList(headers) 712 .stream() 713 .filter(StringUtils::isNotEmpty) 714 .map(header -> _transformHeaderToMap(contentType, header)) 715 .collect(Collectors.toList()); 716 } 717 718 private Map<String, Object> _transformHeaderToMap(ContentType contentType, String header) 719 { 720 Map<String, Object> headerMap = new HashMap<>(); 721 headerMap.put(ImportCSVFileHelper.MAPPING_COLUMN_HEADER, header); 722 headerMap.put( 723 MAPPING_COLUMN_ATTRIBUTE_PATH, 724 Optional.of(header) 725 .map(s -> s.replace("*", "")) 726 .map(s -> s.replace(".", "/")) 727 .map(String::trim) 728 .filter(contentType::hasModelItem) 729 .orElse(StringUtils.EMPTY) 730 ); 731 headerMap.put(ImportCSVFileHelper.MAPPING_COLUMN_IS_ID, header.endsWith("*")); 732 return headerMap; 733 } 734 735 private List<Map<String, Object>> _filterMapping(List<Map<String, Object>> mappingValues) 736 { 737 // Suppress empty attributePath 738 // Transform attributePath with . to attributePath with / (Autocompletion in IHM has . as separator, but the API need /) 739 return mappingValues.stream() 740 .map(column -> 741 { 742 Optional<String> attributePath = Optional.of(MAPPING_COLUMN_ATTRIBUTE_PATH) 743 .map(column::get) 744 .map(String.class::cast) 745 .filter(StringUtils::isNotEmpty) 746 .map(s -> s.replace(".", "/")); 747 748 if (attributePath.isEmpty()) 749 { 750 return null; 751 } 752 753 // Replace original column 754 Map<String, Object> columnCopy = new HashMap<>(column); 755 columnCopy.put(MAPPING_COLUMN_ATTRIBUTE_PATH, attributePath.get()); 756 return columnCopy; 757 } 758 ) 759 .filter(Predicate.not(Objects::isNull)) 760 .collect(Collectors.toList()); 761 } 762}