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