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