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