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