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