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