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}