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.IOException; 019import java.util.ArrayList; 020import java.util.Arrays; 021import java.util.Collection; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Map; 025import java.util.Map.Entry; 026import java.util.Objects; 027import java.util.Optional; 028import java.util.Set; 029import java.util.function.Function; 030import java.util.stream.Collectors; 031import java.util.stream.Stream; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.commons.lang3.LocaleUtils; 038import org.apache.commons.lang3.StringUtils; 039import org.supercsv.io.ICsvListReader; 040import org.supercsv.util.Util; 041 042import org.ametys.cms.ObservationConstants; 043import org.ametys.cms.contenttype.ContentAttributeDefinition; 044import org.ametys.cms.contenttype.ContentType; 045import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 046import org.ametys.cms.data.ContentValue; 047import org.ametys.cms.data.type.BaseMultilingualStringElementType; 048import org.ametys.cms.data.type.impl.MultilingualStringRepositoryElementType; 049import org.ametys.cms.indexing.solr.SolrIndexHelper; 050import org.ametys.cms.repository.Content; 051import org.ametys.cms.repository.ContentQueryHelper; 052import org.ametys.cms.repository.ModifiableDefaultContent; 053import org.ametys.cms.repository.ModifiableWorkflowAwareContent; 054import org.ametys.cms.workflow.AbstractContentWorkflowComponent; 055import org.ametys.cms.workflow.ContentWorkflowHelper; 056import org.ametys.cms.workflow.CreateContentFunction; 057import org.ametys.core.util.I18nUtils; 058import org.ametys.plugins.contentio.csv.SynchronizeModeEnumerator.ImportMode; 059import org.ametys.plugins.contentio.in.ContentImportException; 060import org.ametys.plugins.repository.AmetysObjectIterable; 061import org.ametys.plugins.repository.AmetysObjectResolver; 062import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater; 063import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 064import org.ametys.plugins.repository.data.holder.group.Repeater; 065import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater; 066import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 067import org.ametys.plugins.repository.data.holder.values.SynchronizableValue.Mode; 068import org.ametys.plugins.repository.data.type.ModelItemTypeConstants; 069import org.ametys.plugins.repository.metadata.MultilingualString; 070import org.ametys.plugins.repository.metadata.MultilingualStringHelper; 071import org.ametys.plugins.repository.query.expression.AndExpression; 072import org.ametys.plugins.repository.query.expression.Expression; 073import org.ametys.plugins.repository.query.expression.Expression.Operator; 074import org.ametys.plugins.repository.query.expression.ExpressionContext; 075import org.ametys.plugins.repository.query.expression.MultilingualStringExpression; 076import org.ametys.plugins.repository.query.expression.StringExpression; 077import org.ametys.runtime.model.ElementDefinition; 078import org.ametys.runtime.model.ModelItem; 079import org.ametys.runtime.model.ModelViewItemGroup; 080import org.ametys.runtime.model.View; 081import org.ametys.runtime.model.ViewElement; 082import org.ametys.runtime.model.ViewElementAccessor; 083import org.ametys.runtime.model.ViewItem; 084import org.ametys.runtime.model.type.ElementType; 085import org.ametys.runtime.plugin.component.AbstractLogEnabled; 086 087import com.opensymphony.workflow.WorkflowException; 088 089/** 090 * Import contents from an uploaded CSV file. 091 */ 092public class CSVImporter extends AbstractLogEnabled implements Component, Serviceable 093{ 094 095 /** Avalon Role */ 096 public static final String ROLE = CSVImporter.class.getName(); 097 098 /** Result key containing updated content's ids */ 099 public static final String RESULT_CONTENT_IDS = "contentIds"; 100 /** Result key containing number of errors */ 101 public static final String RESULT_NB_ERRORS = "nbErrors"; 102 /** Result key containing number of warnings */ 103 public static final String RESULT_NB_WARNINGS = "nbWarnings"; 104 105 private ContentWorkflowHelper _contentWorkflowHelper; 106 107 private ContentTypeExtensionPoint _contentTypeEP; 108 109 private AmetysObjectResolver _resolver; 110 111 private I18nUtils _i18nUtils; 112 113 private SolrIndexHelper _solrIndexHelper; 114 115 public void service(ServiceManager smanager) throws ServiceException 116 { 117 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 118 _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE); 119 _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE); 120 _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE); 121 _solrIndexHelper = (SolrIndexHelper) smanager.lookup(SolrIndexHelper.ROLE); 122 } 123 124 /** 125 * Extract contents from CSV file 126 * @param mapping mapping of content attributes and CSV file header 127 * @param view View of importing content 128 * @param contentType content type to import 129 * @param listReader mapReader to parse CSV file 130 * @param createAction creation action id 131 * @param editAction edition action id 132 * @param workflowName workflow name 133 * @param language language of created content. 134 * @param importMode The import mode 135 * @param parentContent Optional parent content 136 * @param additionalTransientVars The optional additional transient vars to add to the transient vars 137 * @return list of created contents 138 * @throws IOException IOException while reading CSV 139 */ 140 public Map<String, Object> importContentsFromCSV(Map<String, Object> mapping, View view, ContentType contentType, ICsvListReader listReader, int createAction, int editAction, String workflowName, String language, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws IOException 141 { 142 try 143 { 144 _solrIndexHelper.pauseSolrCommitForEvents(_getIndexationEvents()); 145 146 List<String> contentIds = new ArrayList<>(); 147 String[] columns = listReader.getHeader(true); 148 int nbErrors = 0; 149 int nbWarnings = 0; 150 List<String> row = null; 151 152 while ((row = listReader.read()) != null) 153 { 154 try 155 { 156 if (listReader.length() != columns.length) 157 { 158 getLogger().error("[{}] Import from CSV file: content skipped because of invalid row: {}", contentType.getId(), row); 159 nbErrors++; 160 continue; 161 } 162 163 Map<String, String> rowMap = new HashMap<>(); 164 Util.filterListToMap(rowMap, columns, row); 165 List<ViewItem> errors = new ArrayList<>(); 166 SynchronizeResult synchronizeResult = processContent(view, rowMap, contentType, mapping, createAction, editAction, workflowName, language, errors, importMode, parentContent, additionalTransientVars); 167 Optional<ModifiableWorkflowAwareContent> content = synchronizeResult.content(); 168 169 // If we are in CREATE_ONLY mode but content was not created, do not add it to the contents list 170 if (content.isPresent() && (importMode != ImportMode.CREATE_ONLY || synchronizeResult.isCreated())) 171 { 172 contentIds.add(content.get().getId()); 173 } 174 175 if (!errors.isEmpty()) 176 { 177 nbWarnings++; 178 } 179 } 180 catch (Exception e) 181 { 182 nbErrors++; 183 getLogger().error("[{}] Import from CSV file: error importing the content on line {}", contentType.getId(), listReader.getLineNumber(), e); 184 } 185 } 186 187 Map<String, Object> results = new HashMap<>(); 188 results.put(RESULT_CONTENT_IDS, contentIds); 189 results.put(RESULT_NB_ERRORS, nbErrors); 190 results.put(RESULT_NB_WARNINGS, nbWarnings); 191 return results; 192 } 193 finally 194 { 195 _solrIndexHelper.restartSolrCommitForEvents(_getIndexationEvents()); 196 } 197 } 198 199 private String[] _getIndexationEvents() 200 { 201 return new String[] { 202 ObservationConstants.EVENT_CONTENT_ADDED, 203 ObservationConstants.EVENT_CONTENT_MODIFIED, 204 ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, 205 ObservationConstants.EVENT_CONTENT_VALIDATED 206 }; 207 } 208 209 /** 210 * Process content for CSV import or synchronization 211 * @param view The view 212 * @param row The row containing the values 213 * @param contentType The content type 214 * @param mapping The mapping 215 * @param createAction The create action 216 * @param editAction The edit action 217 * @param workflowName The wokflow name 218 * @param language The language 219 * @param errors The errors 220 * @param importMode The import mode 221 * @param parentContent The parent content 222 * @param additionalTransientVars The additional transient vars 223 * @return The synchronize result 224 * @throws Exception If an error occurs 225 */ 226 protected SynchronizeResult processContent(View view, Map<String, String> row, ContentType contentType, Map<String, Object> mapping, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws Exception 227 { 228 @SuppressWarnings("unchecked") 229 List<String> attributeIdNames = (List<String>) mapping.get(ImportCSVFileHelper.NESTED_MAPPING_ID); 230 @SuppressWarnings("unchecked") 231 Map<String, Object> mappingValues = (Map<String, Object>) mapping.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES); 232 233 SynchronizeResult synchronizeResult = _synchronizeContent(row, contentType, view, attributeIdNames, mappingValues, createAction, editAction, workflowName, language, errors, importMode, parentContent, additionalTransientVars); 234 return synchronizeResult; 235 } 236 237 private void _editContent(int editAction, View view, Map<String, Object> values, ModifiableWorkflowAwareContent content) throws WorkflowException 238 { 239 Collection<ModelItem> viewItemsDiff = content.getDifferences(view, values); 240 if (!viewItemsDiff.isEmpty()) 241 { 242 _contentWorkflowHelper.editContent(content, values, editAction, View.of(viewItemsDiff.toArray(ModelItem[]::new))); 243 } 244 } 245 246 private Object _getValue(Optional<? extends Content> parentContent, ViewItem viewItem, Map<String, Object> mapping, Map<String, String> row, int createAction, int editAction, String language, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars) throws Exception 247 { 248 if (viewItem instanceof ViewElement viewElement) 249 { 250 ElementDefinition elementDefinition = viewElement.getDefinition(); 251 if (elementDefinition instanceof ContentAttributeDefinition contentAttributeDefinition && viewElement instanceof ViewElementAccessor viewElementAccessor) 252 { 253 return _getContentAttributeDefinitionValues(parentContent, mapping, row, createAction, editAction, language, viewElementAccessor, contentAttributeDefinition, errors, importMode, additionalTransientVars); 254 } 255 else 256 { 257 return _getAttributeDefinitionValues(parentContent, mapping, row, elementDefinition, language, prefix); 258 } 259 } 260 else if (viewItem instanceof ModelViewItemGroup modelViewItemGroup) 261 { 262 List<ViewItem> children = modelViewItemGroup.getViewItems(); 263 @SuppressWarnings("unchecked") 264 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewItem.getName()); 265 @SuppressWarnings("unchecked") 266 Map<String, Object> nestedMapValues = (Map<String, Object>) (nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES)); 267 if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelViewItemGroup.getDefinition().getType().getId())) 268 { 269 return _getRepeaterValues(parentContent, modelViewItemGroup, row, createAction, editAction, language, children, nestedMap, errors, prefix, importMode, additionalTransientVars); 270 } 271 else 272 { 273 return _getCompositeValues(parentContent, viewItem, row, createAction, editAction, language, children, nestedMapValues, errors, prefix, importMode, additionalTransientVars); 274 } 275 } 276 else 277 { 278 errors.add(viewItem); 279 throw new RuntimeException("Import from CSV file: unsupported type of ViewItem for view: " + viewItem.getName()); 280 } 281 } 282 283 private Map<String, Object> _getCompositeValues(Optional<? extends Content> parentContent, ViewItem viewItem, Map<String, String> row, int createAction, int editAction, 284 String language, List<ViewItem> children, Map<String, Object> nestedMapValues, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars) 285 { 286 Map<String, Object> compositeValues = new HashMap<>(); 287 for (ViewItem child : children) 288 { 289 try 290 { 291 compositeValues.put(child.getName(), _getValue(parentContent, child, nestedMapValues, row, createAction, editAction, language, errors, prefix + viewItem.getName() + ModelItem.ITEM_PATH_SEPARATOR, importMode, additionalTransientVars)); 292 } 293 catch (Exception e) 294 { 295 errors.add(viewItem); 296 getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e); 297 } 298 } 299 return compositeValues; 300 } 301 302 private SynchronizableRepeater _getRepeaterValues(Optional<? extends Content> parentContent, ModelViewItemGroup viewItem, Map<String, String> row, int createAction, int editAction, String language, 303 List<ViewItem> children, Map<String, Object> nestedMap, List<ViewItem> errors, String prefix, ImportMode importMode, Map<String, Object> additionalTransientVars) 304 { 305 @SuppressWarnings("unchecked") 306 Map<String, Object> mappingValues = (Map<String, Object>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES); 307 @SuppressWarnings("unchecked") 308 List<String> attributeIdNames = (List<String>) nestedMap.getOrDefault(ImportCSVFileHelper.NESTED_MAPPING_ID, List.of()); 309 Map<String, Object> repeaterValues = new HashMap<>(); 310 List<Map<String, Object>> repeaterValuesList = new ArrayList<>(); 311 List<Integer> indexList = new ArrayList<>(); 312 313 Optional<ModelAwareRepeater> repeater = parentContent.map(p -> p.getRepeater(prefix + viewItem.getName())); 314 315 if (repeater.isPresent() && !attributeIdNames.isEmpty() && _allAttributesFilled(mappingValues, attributeIdNames)) 316 { 317 indexList = repeater.get() 318 .getEntries() 319 .stream() 320 .filter(entry -> 321 { 322 return attributeIdNames.stream() 323 .allMatch(attributeName -> 324 { 325 // Get the entry value 326 Object entryValue = entry.getValue(attributeName); 327 328 // Get the row value for the attribute (from CSV file) 329 Object rowValue = Optional.of(attributeName) 330 .map(mappingValues::get) 331 .map(String.class::cast) 332 .map(row::get) 333 .orElse(null); 334 335 // Transform the row value to the right type then compare entry value and row value 336 return Optional.of(attributeName) 337 .map(viewItem::getModelViewItem) 338 .map(ViewElement.class::cast) 339 .map(ViewElement::getDefinition) 340 .map(ElementDefinition::getType) 341 .map(def -> def.castValue(rowValue)) 342 .map(value -> value.equals(entryValue)) 343 .orElse(false); 344 }); 345 }) 346 .map(ModelAwareRepeaterEntry::getPosition) 347 .collect(Collectors.toList()); 348 } 349 350 // If entries match with attribute ids, we replace the first entries 351 // Else if the repeater exists, we assume we are on the next model item after the last one 352 // Else (the repeater doesn't exist, we set the rowIndex to first index 353 Integer rowIndex = indexList.isEmpty() ? repeater.map(Repeater::getSize).orElse(1) : indexList.get(0); 354 355 for (ViewItem child : children) 356 { 357 try 358 { 359 Object entryValues = _getValue(parentContent, child, mappingValues, row, createAction, editAction, language, errors, prefix + viewItem.getName() + "[" + rowIndex + "]" + ModelItem.ITEM_PATH_SEPARATOR, importMode, additionalTransientVars); 360 if (entryValues != null) 361 { 362 repeaterValues.put(child.getName(), entryValues); 363 } 364 } 365 catch (Exception e) 366 { 367 errors.add(viewItem); 368 getLogger().error("Import from CSV file: error while trying to get values for view: {}", viewItem.getName(), e); 369 } 370 } 371 repeaterValuesList.add(repeaterValues); 372 373 if (indexList.isEmpty()) 374 { 375 return SynchronizableRepeater.appendOrRemove(repeaterValuesList, Set.of()); 376 } 377 else 378 { 379 // If several rows match the id, only replace the first but add a warning 380 if (indexList.size() > 1) 381 { 382 errors.add(viewItem); 383 } 384 return SynchronizableRepeater.replace(repeaterValuesList, List.of(rowIndex)); 385 } 386 } 387 388 private Object _getContentAttributeDefinitionValues(Optional<? extends Content> parentContent, Map<String, Object> mapping, Map<String, String> row, 389 int createAction, int editAction, String language, ViewElementAccessor viewElementAccessor, ContentAttributeDefinition contentAttributeDefinition, List<ViewItem> errors, ImportMode importMode, Map<String, Object> additionalTransientVars) throws Exception 390 { 391 String contentTypeId = contentAttributeDefinition.getContentTypeId(); 392 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 393 @SuppressWarnings("unchecked") 394 Map<String, Object> nestedMap = (Map<String, Object>) mapping.get(viewElementAccessor.getName()); 395 @SuppressWarnings("unchecked") 396 Map<String, Object> mappingValues = (Map<String, Object>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_VALUES); 397 @SuppressWarnings("unchecked") 398 List<String> attributeIdNames = (List<String>) nestedMap.get(ImportCSVFileHelper.NESTED_MAPPING_ID); 399 if (_allAttributesFilled(mappingValues, attributeIdNames)) 400 { 401 View view = new View(); 402 view.addViewItems(viewElementAccessor.getViewItems()); 403 SynchronizeResult synchronizeResult = _synchronizeContent(row, contentType, view, attributeIdNames, mappingValues, createAction, editAction, null, language, errors, importMode, parentContent, additionalTransientVars); 404 405 Optional<ModifiableWorkflowAwareContent> attachedContent = synchronizeResult.content(); 406 407 if (synchronizeResult.hasKey && attachedContent.isEmpty() && importMode == ImportMode.UPDATE_ONLY) 408 { 409 errors.add(viewElementAccessor); 410 } 411 412 // If content is multiple, we keep the old value list and we check if content was already inside, and add it otherwise 413 if (attachedContent.isPresent() && contentAttributeDefinition.isMultiple()) 414 { 415 Optional<ContentValue[]> multipleContents = parentContent.map(c -> c.getValue(contentAttributeDefinition.getPath())); 416 if (!_containsContent(attachedContent.get(), multipleContents)) 417 { 418 // If there is no list or if it is empty, add it. Otherwise, we have to check if it is inside. 419 SynchronizableValue syncValue = new SynchronizableValue(List.of(attachedContent.get())); 420 syncValue.setMode(Mode.APPEND); 421 return syncValue; 422 } 423 else if (multipleContents.isPresent()) 424 { 425 // return existing values as otherwise the values would be erased 426 SynchronizableValue syncValue = new SynchronizableValue(Arrays.asList(multipleContents.get())); 427 syncValue.setMode(Mode.REPLACE); 428 return syncValue; 429 } 430 } 431 else 432 { 433 return attachedContent.orElse(null); 434 } 435 } 436 return null; 437 } 438 439 private boolean _containsContent(ModifiableWorkflowAwareContent attachedContent, Optional<ContentValue[]> multipleContents) 440 { 441 return multipleContents 442 .map(Arrays::stream) 443 .orElseGet(Stream::empty) 444 .map(ContentValue::getContentId) 445 .anyMatch(valueFromContent -> valueFromContent.equals(attachedContent.getId())); 446 } 447 448 private Object _getAttributeDefinitionValues(Optional<? extends Content> parentContent, Map<String, Object> mapping, Map<String, String> row, ElementDefinition elementDefinition, String language, String prefix) 449 { 450 ElementType elementType = elementDefinition.getType(); 451 String elementName = elementDefinition.getName(); 452 String elementColumn = (String) mapping.get(elementName); 453 String valueAsString = row.get(elementColumn); 454 455 Object value; 456 if (elementType instanceof BaseMultilingualStringElementType && !MultilingualStringHelper.matchesMultilingualStringPattern(valueAsString)) 457 { 458 MultilingualString multilingualString = new MultilingualString(); 459 multilingualString.add(LocaleUtils.toLocale(language), valueAsString); 460 value = multilingualString; 461 } 462 else 463 { 464 value = elementType.castValue(valueAsString); 465 } 466 467 if (elementDefinition.isMultiple()) 468 { 469 // Build path with index for repeaters. 470 String pathWithIndex = prefix + elementDefinition.getName(); 471 472 Optional<Object[]> values = parentContent.map(c -> c.getValue(pathWithIndex)); 473 if (!_containsValue(value, parentContent.map(c -> c.getValue(pathWithIndex)))) 474 { 475 // If there is no list or if it is empty, add it. Otherwise, we have to check if it is inside. 476 // If there is no parentContent, still append as we want to fill the values map anyway. 477 SynchronizableValue syncValue = new SynchronizableValue(value != null ? List.of(value) : List.of()); 478 syncValue.setMode(Mode.APPEND); 479 return syncValue; 480 } 481 else if (values.isPresent()) 482 { 483 // return existing values as otherwise the values would be erased 484 SynchronizableValue syncValue = new SynchronizableValue(Arrays.asList(values.get())); 485 syncValue.setMode(Mode.REPLACE); 486 return syncValue; 487 } 488 } 489 else 490 { 491 return value; 492 } 493 494 return null; 495 } 496 497 private boolean _containsValue(Object value, Optional<Object[]> multipleValues) 498 { 499 return multipleValues 500 .map(Arrays::stream) 501 .orElseGet(Stream::empty) 502 .anyMatch(valueFromContent -> valueFromContent.equals(value)); 503 } 504 505 private SynchronizeResult _synchronizeContent(Map<String, String> row, ContentType contentType, View view, List<String> attributeIdNames, Map<String, Object> mappingValues, int createAction, int editAction, String workflowName, String language, List<ViewItem> errors, ImportMode importMode, Optional< ? extends Content> parentContent, Map<String, Object> additionalTransientVars) throws Exception 506 { 507 SynchronizeResult synchronizeResult = _getOrCreateContent(mappingValues, row, contentType, Optional.ofNullable(workflowName), createAction, language, attributeIdNames, parentContent, importMode, additionalTransientVars); 508 Optional<ModifiableWorkflowAwareContent> content = synchronizeResult.content(); 509 510 // If we are on CREATE_ONLY mode and content already exists, or if we are on UPDATE_ONLY mode and content does not exists, stop recursivity 511 if (importMode == ImportMode.CREATE_ONLY && !synchronizeResult.isCreated() || importMode == ImportMode.UPDATE_ONLY && content.isEmpty()) 512 { 513 return synchronizeResult; 514 } 515 516 Map<String, Object> values = _getValues(content, row, view, mappingValues, createAction, editAction, language, errors, importMode, additionalTransientVars); 517 if (!values.isEmpty()) 518 { 519 if (content.isEmpty()) 520 { 521 // Throw this exception only when values are filled, as an empty content should not trigger any warning 522 throw new ContentImportException("Can't create and fill content of content type '" + contentType.getId() + "' and following values '" + values + "' : at least one of those identifiers is null : " + attributeIdNames); 523 } 524 else 525 { 526 try 527 { 528 _editContent(editAction, view, values, content.get()); 529 } 530 catch (WorkflowException e) 531 { 532 errors.addAll(view.getViewItems()); 533 getLogger().error("[{}] Import from CSV file: error editing the content [{}] after import, some values have not been set", contentType.getId(), content.get().getId(), e); 534 } 535 } 536 } 537 538 return synchronizeResult; 539 } 540 541 /** 542 * Returns a list of additional expression to retrieve the existing content 543 * @param mapping The mapping 544 * @param row The row 545 * @param contentType The content type 546 * @param parentContent The parent content 547 * @param additionalTransientVars Additional transients vars 548 * @param valuesUsedAsIds The values used as ids, if some values are added in expression, they can be added in this list to be printed in error logs 549 * @return a list of additional expression to retrieve the existing content 550 */ 551 protected List<Expression> additionalExpressionToRetrieveContent(Map<String, Object> mapping, Map<String, String> row, ContentType contentType, Optional<? extends Content> parentContent, Map<String, Object> additionalTransientVars, List<String> valuesUsedAsIds) 552 { 553 // No additional expression by default 554 return List.of(); 555 } 556 557 private SynchronizeResult _getOrCreateContent(Map<String, Object> mapping, Map<String, String> row, ContentType contentType, Optional<String> workflowName, int createAction, String language, List<String> attributeIdNames, Optional<? extends Content> parentContent, ImportMode importMode, Map<String, Object> additionalTransientVars) throws ContentImportException, WorkflowException 558 { 559 AndExpression expression = new AndExpression(); 560 List<String> values = new ArrayList<>(); 561 562 for (String attributeName : attributeIdNames) 563 { 564 ModelItem modelItem = contentType.getModelItem(attributeName); 565 String attributePath = (String) mapping.get(attributeName); 566 String value = row.get(attributePath); 567 values.add(value); 568 569 if (value == null) 570 { 571 return new SynchronizeResult(false, Optional.empty(), false); 572 } 573 574 // Get content 575 if (modelItem.getType() instanceof MultilingualStringRepositoryElementType) 576 { 577 expression.add(new MultilingualStringExpression(attributeName, Operator.EQ, value, language)); 578 } 579 else 580 { 581 expression.add(new StringExpression(attributeName, Operator.EQ, value)); 582 } 583 } 584 585 expression.addAll(additionalExpressionToRetrieveContent(mapping, row, contentType, parentContent, additionalTransientVars, values)); 586 587 expression.add(_contentTypeEP.createHierarchicalCTExpression(contentType.getId())); 588 589 if (!contentType.isMultilingual()) 590 { 591 expression.add(new StringExpression("language", Operator.EQ, language, ExpressionContext.newInstance().withInternal(true))); 592 } 593 594 String xPathQuery = ContentQueryHelper.getContentXPathQuery(expression); 595 AmetysObjectIterable<ModifiableDefaultContent> matchingContents = _resolver.query(xPathQuery); 596 if (matchingContents.getSize() > 1) 597 { 598 throw new ContentImportException("More than one content found for type " + contentType.getId() + " with " 599 + attributeIdNames + " as identifier and " + values + " as value"); 600 } 601 else if (matchingContents.getSize() == 1) 602 { 603 return new SynchronizeResult(false, Optional.of(matchingContents.iterator().next()), true); 604 } 605 else if (importMode == ImportMode.UPDATE_ONLY) 606 { 607 return new SynchronizeResult(false, Optional.empty(), true); 608 } 609 610 // Create content 611 612 if (contentType.isAbstract()) 613 { 614 throw new ContentImportException("Can not create content for type " + contentType.getId() + " with " 615 + attributeIdNames + " as identifier and " + values + " as value, the content type is abstract"); 616 } 617 618 Map<String, Object> result; 619 String title; 620 if (mapping.containsKey("title")) 621 { 622 title = row.get(mapping.get("title")); 623 } 624 else 625 { 626 title = _i18nUtils.translate(contentType.getDefaultTitle(), language); 627 } 628 629 630 String finalWorkflowName = workflowName.or(contentType::getDefaultWorkflowName) 631 .orElseThrow(() -> new ContentImportException("No workflow specified for content type " + contentType.getId() + " with " 632 + attributeIdNames + " as identifier and " + values + " as value")); 633 634 Map<String, Object> inputs = new HashMap<>(); 635 inputs.put(CreateContentFunction.INITIAL_VALUE_SUPPLIER, new Function<List<String>, Object>() 636 { 637 public Object apply(List<String> keys) 638 { 639 // Browse the mapping to find the column related to the attribute 640 Object nestedValue = mapping; 641 for (String key : keys) 642 { 643 nestedValue = ((Map) nestedValue).get(key); 644 // If nestedValue is null, the attribute is absent from the map, no value can be found 645 if (nestedValue == null) 646 { 647 return null; 648 } 649 // If nestedValue is a map, the key is a complex element such a content or a composite, 650 // we need to keep browsing the map to find the column 651 if (nestedValue instanceof Map) 652 { 653 nestedValue = ((Map) nestedValue).get(ImportCSVFileHelper.NESTED_MAPPING_VALUES); 654 } 655 } 656 657 // Get the value of the attribute for the current row 658 return row.get(nestedValue.toString()); 659 } 660 }); 661 662 parentContent.ifPresent(content -> inputs.put(CreateContentFunction.PARENT_CONTEXT_VALUE, content.getId())); 663 664 // Add all additional transient vars 665 inputs.putAll(additionalTransientVars); 666 667 // CONTENTIO-253 To avoid issue with title starting with a non letter character, we prefix the name with the contentTypeId 668 String prefix = StringUtils.substringAfterLast(contentType.getId(), ".").toLowerCase(); 669 String contentName = prefix + "-" + title; 670 671 if (contentType.isMultilingual()) 672 { 673 inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, language); 674 result = _contentWorkflowHelper.createContent(finalWorkflowName, createAction, contentName, Map.of(language, title), new String[] {contentType.getId()}, null, inputs); 675 } 676 else 677 { 678 result = _contentWorkflowHelper.createContent(finalWorkflowName, createAction, contentName, title, new String[] {contentType.getId()}, null, language, inputs); 679 } 680 681 ModifiableWorkflowAwareContent content = (ModifiableWorkflowAwareContent) result.get(AbstractContentWorkflowComponent.CONTENT_KEY); 682 return new SynchronizeResult(true, Optional.of(content), true); 683 } 684 685 private Map<String, Object> _getValues(Optional<ModifiableWorkflowAwareContent> content, Map<String, String> row, View view, Map<String, Object> mappingValues, int createAction, int editAction, String language, List<ViewItem> errors, ImportMode importMode, Map<String, Object> additionalTransientVars) 686 { 687 Map<String, Object> values = new HashMap<>(); 688 689 for (ViewItem viewItem : view.getViewItems()) 690 { 691 try 692 { 693 Object value = _getValue(content, viewItem, mappingValues, row, createAction, editAction, language, errors, StringUtils.EMPTY, importMode, additionalTransientVars); 694 if (value != null) 695 { 696 values.put(viewItem.getName(), value); 697 } 698 } 699 catch (Exception e) 700 { 701 errors.add(viewItem); 702 getLogger().error("Import from CSV file: error while trying to get values for item '{}'", viewItem.getName(), e); 703 } 704 } 705 706 return values; 707 } 708 709 private boolean _allAttributesFilled(Map<String, Object> mappingValues, List<String> attributeNames) 710 { 711 return mappingValues.entrySet() 712 .stream() 713 .filter(entry -> attributeNames.contains(entry.getKey())) 714 .map(Entry::getValue) 715 .allMatch(Objects::nonNull); 716 } 717 718 /** 719 * Synchronize result 720 * @param isCreated If the content was created 721 * @param content The content 722 * @param hasKey has key 723 */ 724 protected record SynchronizeResult(boolean isCreated, Optional<ModifiableWorkflowAwareContent> content, boolean hasKey) { /* empty */ } 725 726}