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