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