001/* 002 * Copyright 2019 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.archive; 017 018import java.io.IOException; 019import java.net.URI; 020import java.nio.file.Path; 021import java.nio.file.attribute.BasicFileAttributes; 022import java.util.ArrayList; 023import java.util.Collection; 024import java.util.Date; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Objects; 029import java.util.Optional; 030import java.util.function.Predicate; 031import java.util.stream.Collectors; 032import java.util.stream.Stream; 033import java.util.zip.ZipEntry; 034import java.util.zip.ZipOutputStream; 035 036import javax.jcr.AccessDeniedException; 037import javax.jcr.ItemNotFoundException; 038import javax.jcr.Node; 039import javax.jcr.RepositoryException; 040import javax.xml.parsers.DocumentBuilder; 041import javax.xml.parsers.DocumentBuilderFactory; 042import javax.xml.parsers.ParserConfigurationException; 043import javax.xml.transform.TransformerConfigurationException; 044import javax.xml.transform.TransformerException; 045import javax.xml.transform.sax.TransformerHandler; 046import javax.xml.transform.stream.StreamResult; 047 048import org.apache.avalon.framework.component.Component; 049import org.apache.avalon.framework.service.ServiceException; 050import org.apache.avalon.framework.service.ServiceManager; 051import org.apache.avalon.framework.service.Serviceable; 052import org.apache.commons.lang3.StringUtils; 053import org.slf4j.Logger; 054import org.w3c.dom.Document; 055import org.xml.sax.SAXException; 056 057import org.ametys.cms.content.references.OutgoingReferences; 058import org.ametys.cms.content.references.OutgoingReferencesExtractor; 059import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 060import org.ametys.cms.repository.Content; 061import org.ametys.cms.repository.DefaultContent; 062import org.ametys.cms.repository.ModifiableContent; 063import org.ametys.cms.repository.ModifiableContentHelper; 064import org.ametys.cms.repository.WorkflowAwareContent; 065import org.ametys.cms.repository.WorkflowAwareContentHelper; 066import org.ametys.core.user.UserIdentity; 067import org.ametys.plugins.contentio.archive.Archivers.AmetysObjectNotImportedException; 068import org.ametys.plugins.repository.AmetysObjectIterable; 069import org.ametys.plugins.repository.AmetysObjectResolver; 070import org.ametys.plugins.repository.TraversableAmetysObject; 071import org.ametys.plugins.repository.collection.AmetysObjectCollection; 072import org.ametys.plugins.repository.data.extractor.xml.XMLValuesExtractorAdditionalDataGetter; 073import org.ametys.plugins.repository.jcr.JCRAmetysObject; 074import org.ametys.plugins.repository.jcr.JCRTraversableAmetysObject; 075import org.ametys.plugins.workflow.support.WorkflowProvider; 076import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow; 077import org.ametys.runtime.plugin.component.AbstractLogEnabled; 078 079import com.opensymphony.workflow.WorkflowException; 080import com.opensymphony.workflow.spi.Step; 081 082/** 083 * Export a contents collection as individual XML files. 084 */ 085public class ContentsArchiverHelper extends AbstractLogEnabled implements Component, Serviceable 086{ 087 /** Avalon role. */ 088 public static final String ROLE = ContentsArchiverHelper.class.getName(); 089 090 private static final String __CONTENT_ZIP_ENTRY_FILENAME = "content.xml"; 091 private static final String __ACL_ZIP_ENTRY_FILENAME = "_acl.xml"; 092 093 private AmetysObjectResolver _resolver; 094 private ResourcesArchiverHelper _resourcesArchiverHelper; 095 private WorkflowProvider _workflowProvider; 096 private ModifiableContentHelper _modifiableContentHelper; 097 private OutgoingReferencesExtractor _outgoingReferencesExtractor; 098 private ContentTypeExtensionPoint _contentTypeEP; 099 100 @Override 101 public void service(ServiceManager manager) throws ServiceException 102 { 103 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 104 _resourcesArchiverHelper = (ResourcesArchiverHelper) manager.lookup(ResourcesArchiverHelper.ROLE); 105 _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE); 106 _modifiableContentHelper = (ModifiableContentHelper) manager.lookup(ModifiableContentHelper.ROLE); 107 _outgoingReferencesExtractor = (OutgoingReferencesExtractor) manager.lookup(OutgoingReferencesExtractor.ROLE); 108 _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 109 } 110 111 /** 112 * Exports contents from a root Node. 113 * @param prefix the prefix for the ZIP archive. 114 * @param rootNode the root JCR Node holding the contents collection. 115 * @param zos the ZIP OutputStream. 116 * @throws RepositoryException if an error occurs while resolving Node. 117 * @throws IOException if an error occurs while archiving 118 */ 119 public void exportContents(String prefix, Node rootNode, ZipOutputStream zos) throws RepositoryException, IOException 120 { 121 TraversableAmetysObject rootContents = _resolver.resolve(rootNode, false); 122 exportContents(prefix, rootContents, zos); 123 } 124 125 /** 126 * Exports contents from a root AmetysObject. 127 * @param prefix the prefix for the ZIP archive. 128 * @param rootContents the root JCR Node holding the contents collection. 129 * @param zos the ZIP OutputStream. 130 * @throws IOException if an error occurs while archiving 131 */ 132 public void exportContents(String prefix, TraversableAmetysObject rootContents, ZipOutputStream zos) throws IOException 133 { 134 zos.putNextEntry(new ZipEntry(StringUtils.appendIfMissing(prefix, "/"))); // even if there is no child, at least export the root of contents 135 136 AmetysObjectIterable<Content> contents = rootContents.getChildren(); 137 for (Content content : contents) 138 { 139 _exportContent(prefix, content, zos); 140 } 141 142 // finally process ACL for the contents root 143 try 144 { 145 Node contentNode = ((JCRAmetysObject) rootContents).getNode(); 146 Archivers.exportAcl(contentNode, zos, prefix + __ACL_ZIP_ENTRY_FILENAME); 147 } 148 catch (RepositoryException e) 149 { 150 throw new RuntimeException("Unable to SAX ACL for root contents at '" + rootContents.getPath() + "' for archiving", e); 151 } 152 } 153 154 private void _exportContent(String prefix, Content content, ZipOutputStream zos) throws IOException 155 { 156 List<String> unexistingContentTypesAndMixins = Stream.of(content.getTypes(), content.getMixinTypes()) 157 .flatMap(Stream::of) 158 .filter(Predicate.not(_contentTypeEP::hasExtension)) 159 .collect(Collectors.toList()); 160 if (!unexistingContentTypesAndMixins.isEmpty()) 161 { 162 getLogger().error("Content \"{}\" will not be exported as at least one of its types or mixins does not exist: {}", content, unexistingContentTypesAndMixins); 163 return; 164 } 165 166 // for each content, first an XML file with attributes, comments, tags, ... 167 String name = content.getName(); 168 String path = prefix + Archivers.getHashedPath(name) + "/" + name + "/"; 169 ZipEntry contentEntry = new ZipEntry(path + __CONTENT_ZIP_ENTRY_FILENAME); 170 zos.putNextEntry(contentEntry); 171 172 try 173 { 174 TransformerHandler contentHandler = Archivers.newTransformerHandler(); 175 contentHandler.setResult(new StreamResult(zos)); 176 177 contentHandler.startDocument(); 178 content.toSAX(contentHandler, null, null, true); 179 contentHandler.endDocument(); 180 } 181 catch (SAXException | TransformerConfigurationException e) 182 { 183 throw new RuntimeException("Unable to SAX content '" + content.getPath() + "' for archiving", e); 184 } 185 186 // then all attachments 187 _resourcesArchiverHelper.exportCollection(content.getRootAttachments(), zos, path + "_attachments/"); 188 189 // then all files local to rich texts (images) 190 Archivers.exportRichTexts(content, zos, path); 191 192 // then all binary attributes 193 Archivers.exportBinaries(content, zos, path); 194 195 // then all file attributes 196 Archivers.exportFiles(content, zos, path); 197 198 // then ACL 199 try 200 { 201 Node contentNode = ((JCRAmetysObject) content).getNode(); 202 Archivers.exportAcl(contentNode, zos, path + __ACL_ZIP_ENTRY_FILENAME); 203 } 204 catch (RepositoryException e) 205 { 206 throw new RuntimeException("Unable to SAX ACL for content '" + content.getPath() + "' for archiving", e); 207 } 208 } 209 210 /** 211 * Imports contents from the given ZIP archive and path, under the given root of contents 212 * @param commonPrefix The common prefix in the ZIP archive 213 * @param rootContents the root {@link JCRTraversableAmetysObject} holding the contents collection. 214 * @param zipPath the input zip path 215 * @param merger The {@link Merger} 216 * @param contentFillers The fillers in order to fill additional attributes on imported contents 217 * @return The {@link ImportReport} 218 * @throws IOException if an error occurs while importing archive 219 */ 220 public ImportReport importContents(String commonPrefix, AmetysObjectCollection rootContents, Path zipPath, Merger merger, Collection<ContentFiller> contentFillers) throws IOException 221 { 222 Importer importer; 223 List<DefaultContent> createdContents; 224 try 225 { 226 importer = new Importer(commonPrefix, rootContents, zipPath, merger, contentFillers, getLogger()); 227 createdContents = importer.importRoot(); 228 } 229 catch (ParserConfigurationException e) 230 { 231 throw new IOException(e); 232 } 233 _saveContents(rootContents); 234 _checkoutContents(createdContents); 235 return importer._report; 236 } 237 238 private void _saveContents(AmetysObjectCollection rootContents) 239 { 240 if (rootContents.needsSave()) 241 { 242 getLogger().warn(Archivers.WARN_MESSAGE_ROOT_HAS_PENDING_CHANGES, rootContents); 243 rootContents.saveChanges(); 244 } 245 } 246 247 private void _checkoutContents(List<DefaultContent> createdContents) 248 { 249 for (DefaultContent createdContent : createdContents) 250 { 251 createdContent.checkpoint(); 252 } 253 } 254 255 /** 256 * A filler in order to fill additional attributes on imported contents 257 */ 258 @FunctionalInterface 259 public static interface ContentFiller 260 { 261 /** 262 * Fill the content with additional attributes 263 * @param content The imported content 264 */ 265 void fillContent(DefaultContent content); 266 } 267 268 private class Importer 269 { 270 final ImportReport _report = new ImportReport(); 271 private final String _commonPrefix; 272 private final AmetysObjectCollection _root; 273 private final Path _zipArchivePath; 274 private final Merger _merger; 275 private final Collection<ContentFiller> _contentFillers; 276 private final Logger _logger; 277 private final DocumentBuilder _builder; 278 private final UnitaryContentImporter _unitaryImporter = new UnitaryContentImporter(); 279 280 Importer(String commonPrefix, AmetysObjectCollection root, Path zipArchivePath, Merger merger, Collection<ContentFiller> contentFillers, Logger logger) throws ParserConfigurationException 281 { 282 _commonPrefix = commonPrefix; 283 _root = root; 284 _zipArchivePath = zipArchivePath; 285 _merger = merger; 286 _contentFillers = contentFillers; 287 _logger = logger; 288 _builder = DocumentBuilderFactory.newInstance() 289 .newDocumentBuilder(); 290 } 291 292 private class UnitaryContentImporter implements UnitaryImporter<DefaultContent> 293 { 294 @Override 295 public String objectNameForLogs() 296 { 297 return "Content"; 298 } 299 300 @Override 301 public Document getPropertiesXml(Path zipEntryPath) throws Exception 302 { 303 return _getContentPropertiesXml(zipEntryPath); 304 } 305 306 @Override 307 public String retrieveId(Document propertiesXml) throws Exception 308 { 309 return Archivers.xpathEvalNonEmpty("content/@id", propertiesXml); 310 } 311 312 @Override 313 public DefaultContent create(Path zipEntryPath, String id, Document propertiesXml) throws AmetysObjectNotImportedException, Exception 314 { 315 DefaultContent createdContent = _createContent(zipEntryPath, propertiesXml); 316 Node contentNode = createdContent.getNode(); 317 _createContentAcl(contentNode, zipEntryPath); 318 Archivers.unitarySave(contentNode, _logger); 319 return createdContent; 320 } 321 322 @Override 323 public ImportReport getReport() 324 { 325 return _report; 326 } 327 } 328 329 List<DefaultContent> importRoot() throws IOException 330 { 331 _fillRoot(); 332 333 try (Stream<Path> zippedFiles = _matchingZippedFiles()) 334 { 335 // no stream pipeline here because exception flow is important 336 List<DefaultContent> createdContents = new ArrayList<>(); 337 for (Path zipEntryPath : zippedFiles.toArray(Path[]::new)) 338 { 339 Optional<DefaultContent> createdContent = _importContent(zipEntryPath); 340 createdContent.ifPresent(createdContents::add); 341 } 342 return createdContents; 343 } 344 } 345 346 private void _fillRoot() throws IOException 347 { 348 _createRootContentAcl(); 349 try 350 { 351 Archivers.unitarySave(_root.getNode(), _logger); 352 } 353 catch (AmetysObjectNotImportedException e) 354 { 355 // ACL were not exported => it was already logged in error level, and it does not affect the future import of contents => continue 356 } 357 } 358 359 private void _createRootContentAcl() throws IOException 360 { 361 Node rootNode = _root.getNode(); 362 String zipEntryPath = new StringBuilder() 363 .append(StringUtils.strip(_commonPrefix, "/")) 364 .append("/") 365 .append(__ACL_ZIP_ENTRY_FILENAME) 366 .toString(); 367 _createAcl(rootNode, zipEntryPath); 368 } 369 370 private void _createContentAcl(Node contentNode, Path contentZipEntryPath) throws IOException 371 { 372 String zipEntryPath = contentZipEntryPath 373 .getParent() 374 .resolve(__ACL_ZIP_ENTRY_FILENAME) 375 .toString(); 376 _createAcl(contentNode, zipEntryPath); 377 } 378 379 private void _createAcl(Node node, String zipAclEntryPath) throws IOException 380 { 381 try 382 { 383 _logger.debug("Trying to import ACL node for Content (or root of contents) '{}', from ACL XML file '{}', if it exists", node, zipAclEntryPath); 384 Archivers.importAcl(node, _zipArchivePath, _merger, zipAclEntryPath, _logger); 385 } 386 catch (RepositoryException e) 387 { 388 throw new IOException(e); 389 } 390 } 391 392 private Stream<Path> _matchingZippedFiles() throws IOException 393 { 394 return ZipEntryHelper.zipFileTree( 395 _zipArchivePath, 396 Optional.of(_commonPrefix), 397 (Path p, BasicFileAttributes attrs) -> 398 !attrs.isDirectory() 399 && __CONTENT_ZIP_ENTRY_FILENAME.equals(p.getFileName().toString())); 400 } 401 402 private Optional<DefaultContent> _importContent(Path zipEntryPath) throws ImportGlobalFailException 403 { 404 return _unitaryImporter.unitaryImport(_zipArchivePath, zipEntryPath, _merger, _logger); 405 } 406 407 private Document _getContentPropertiesXml(Path zipEntryPath) throws SAXException, IOException 408 { 409 URI zipEntryUri = zipEntryPath.toUri(); 410 return _builder.parse(zipEntryUri.toString()); 411 } 412 413 private DefaultContent _createContent(Path contentZipEntry, Document propertiesXml) throws AmetysObjectNotImportedException, Exception 414 { 415 // At first, check the content types and mixins exist in the current application, otherwise do not import the content ASAP 416 String[] contentTypes = _retrieveContentTypes(contentZipEntry, propertiesXml, "content/contentTypes/contentType"); 417 String[] mixins = _retrieveContentTypes(contentZipEntry, propertiesXml, "content/mixins/mixin"); 418 419 // Create the JCR Node 420 String uuid = Archivers.xpathEvalNonEmpty("content/@uuid", propertiesXml); 421 String contentDesiredName = Archivers.xpathEvalNonEmpty("content/@name", propertiesXml); 422 String type = Archivers.xpathEvalNonEmpty("content/@primaryType", propertiesXml); 423 _logger.info("Creating a Content object for '{}' file (uuid={}, type={}, desiredName={})", contentZipEntry, uuid, type, contentDesiredName); 424 425 DefaultContent createdContent = _createChild(uuid, contentDesiredName, type); 426 427 // Set mandatory properties 428 _setContentMandatoryProperties(createdContent, contentTypes, mixins, propertiesXml); 429 // Set other properties 430 _fillContentNode(createdContent, propertiesXml, contentZipEntry); 431 // Set content attachments 432 ImportReport importAttachmentReport = _fillContentAttachments(createdContent, contentZipEntry); 433 _report.addFrom(importAttachmentReport); 434 // Fill other attributes 435 _fillAdditionalContentAttributes(createdContent); 436 // Outgoing references 437 _setOutgoingReferences(createdContent); 438 439 // Initialize workflow 440 if (createdContent instanceof WorkflowAwareContent) 441 { 442 String workflowName = Archivers.xpathEvalNonEmpty("content/workflow-step/@workflowName", propertiesXml); 443 _handleWorkflow(workflowName, (WorkflowAwareContent) createdContent); 444 } 445 446 return createdContent; 447 } 448 449 private String[] _retrieveContentTypes(Path contentZipEntry, Document propertiesXml, String xPath) throws TransformerException, AmetysObjectNotImportedException 450 { 451 String[] contentTypes = DomNodeHelper.stringValues(propertiesXml, xPath); 452 List<String> unexistingTypes = Stream.of(contentTypes) 453 .filter(Predicate.not(_contentTypeEP::hasExtension)) 454 .collect(Collectors.toList()); 455 if (!unexistingTypes.isEmpty()) 456 { 457 String message = String.format("Content defined in '%s' has at least one of its types or mixins which does not exist: %s", contentZipEntry, unexistingTypes); 458 throw new AmetysObjectNotImportedException(message); 459 } 460 return contentTypes; 461 } 462 463 private DefaultContent _createChild(String uuid, String contentDesiredName, String type) throws AccessDeniedException, ItemNotFoundException, RepositoryException 464 { 465 // Create a content with AmetysObjectCollection.createChild 466 String unusedContentName = _getUnusedContentName(contentDesiredName); 467 JCRAmetysObject srcContent = (JCRAmetysObject) _root.createChild(unusedContentName, type); 468 Node srcNode = srcContent.getNode(); 469 // But then call 'replaceNodeWithDesiredUuid' to have it with the desired UUID (srcNode will be removed) 470 Node nodeWithDesiredUuid = Archivers.replaceNodeWithDesiredUuid(srcNode, uuid); 471 472 // Then resolve and return a Content 473 String parentPath = _root.getPath(); 474 DefaultContent createdContent = _resolver.resolve(parentPath, nodeWithDesiredUuid, null, false); 475 return createdContent; 476 } 477 478 // ~ same algorithm than org.ametys.cms.workflow.CreateContentFunction._createContent 479 // no use of org.ametys.cms.FilterNameHelper.filterName because it was already filtered during the export (taken from the existing content name) 480 private String _getUnusedContentName(String desiredName) 481 { 482 String contentName = desiredName; 483 for (int errorCount = 0; true; errorCount++) 484 { 485 if (errorCount != 0) 486 { 487 _logger.debug("Name '{}' from Content is already used. Trying another one...", contentName); 488 contentName = desiredName + "-" + (errorCount + 1); 489 } 490 if (!_root.hasChild(contentName)) 491 { 492 _logger.debug("Content will be created with unused name '{}'. The desired name was '{}'", contentName, desiredName); 493 return contentName; 494 } 495 } 496 } 497 498 private void _setContentMandatoryProperties(DefaultContent content, String[] contentTypes, String[] mixins, Document propertiesXml) throws TransformerException, AmetysObjectNotImportedException 499 { 500 content.setTypes(contentTypes); 501 content.setMixinTypes(mixins); 502 503 Date creationDate = Objects.requireNonNull(DomNodeHelper.nullableDatetimeValue(propertiesXml, "content/@createdAt")); 504 _modifiableContentHelper.setCreationDate(content, creationDate); 505 506 String creator = Archivers.xpathEvalNonEmpty("content/@creator", propertiesXml); 507 _modifiableContentHelper.setCreator(content, UserIdentity.stringToUserIdentity(creator)); 508 509 Date lastModifiedAt = Objects.requireNonNull(DomNodeHelper.nullableDatetimeValue(propertiesXml, "content/@lastModifiedAt")); 510 _modifiableContentHelper.setLastModified(content, lastModifiedAt); 511 512 String lastContributor = Archivers.xpathEvalNonEmpty("content/@lastContributor", propertiesXml); 513 _modifiableContentHelper.setLastContributor(content, UserIdentity.stringToUserIdentity(lastContributor)); 514 } 515 516 private void _fillContentNode(DefaultContent content, Document propertiesXml, Path contentZipEntry) throws TransformerException, Exception 517 { 518 String language = DomNodeHelper.nullableStringValue(propertiesXml, "content/@language"); 519 if (language != null) 520 { 521 content.setLanguage(language); 522 } 523 524 Date lastValidatedAt = DomNodeHelper.nullableDatetimeValue(propertiesXml, "content/@lastValidatedAt"); 525 if (lastValidatedAt != null) 526 { 527 _modifiableContentHelper.setLastValidationDate(content, lastValidatedAt); 528 } 529 530 if (content instanceof ModifiableContent) 531 { 532 Path contentPath = contentZipEntry.getParent(); 533 _fillContent((ModifiableContent) content, propertiesXml, contentPath); 534 } 535 } 536 537 private void _fillContent(ModifiableContent content, Document propertiesXml, Path contentZipEntry) throws Exception 538 { 539 XMLValuesExtractorAdditionalDataGetter additionalDataGetter = new ResourcesAdditionalDataGetter(_zipArchivePath, contentZipEntry); 540 content.fillContent(propertiesXml, additionalDataGetter); 541 } 542 543 private ImportReport _fillContentAttachments(DefaultContent createdContent, Path contentZipEntry) throws IOException, RepositoryException 544 { 545 Node contentNode = createdContent.getNode(); 546 547 // ametys-internal:attachments is created automatically 548 if (contentNode.hasNode(DefaultContent.ATTACHMENTS_NODE_NAME)) 549 { 550 contentNode.getNode(DefaultContent.ATTACHMENTS_NODE_NAME).remove(); 551 } 552 553 Path contentAttachmentsZipEntryFolder = contentZipEntry.resolveSibling("_attachments/"); 554 String commonPrefix = StringUtils.appendIfMissing(contentAttachmentsZipEntryFolder.toString(), "/"); 555 return _resourcesArchiverHelper.importCollection(commonPrefix, contentNode, _zipArchivePath, _merger); 556 } 557 558 private void _fillAdditionalContentAttributes(DefaultContent content) 559 { 560 for (ContentFiller contentFiller : _contentFillers) 561 { 562 contentFiller.fillContent(content); 563 } 564 } 565 566 private void _setOutgoingReferences(DefaultContent content) 567 { 568 if (content instanceof ModifiableContent) 569 { 570 Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content); 571 ((ModifiableContent) content).setOutgoingReferences(outgoingReferencesByPath); 572 } 573 } 574 575 private void _handleWorkflow(String workflowName, WorkflowAwareContent createdContent) throws WorkflowException 576 { 577 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(createdContent); 578 579 int initialAction = 0; 580 Map<String, Object> inputs = new HashMap<>(Map.of()); 581 long workflowId = workflow.initialize(workflowName, initialAction, inputs); 582 WorkflowAwareContentHelper.setWorkflowId(createdContent, workflowId); 583 584 Step currentStep = (Step) workflow.getCurrentSteps(workflowId).iterator().next(); 585 createdContent.setCurrentStepId(currentStep.getStepId()); 586 } 587 } 588}