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.io.InputStream;
020import java.nio.file.DirectoryStream;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.time.LocalDate;
024import java.time.ZoneId;
025import java.time.ZonedDateTime;
026import java.time.format.DateTimeFormatter;
027import java.util.ArrayList;
028import java.util.Calendar;
029import java.util.Date;
030import java.util.GregorianCalendar;
031import java.util.List;
032import java.util.Objects;
033import java.util.Optional;
034import java.util.zip.ZipEntry;
035import java.util.zip.ZipOutputStream;
036
037import javax.jcr.Binary;
038import javax.jcr.Node;
039import javax.jcr.RepositoryException;
040import javax.jcr.Session;
041import javax.xml.parsers.DocumentBuilder;
042import javax.xml.parsers.DocumentBuilderFactory;
043import javax.xml.parsers.ParserConfigurationException;
044import javax.xml.transform.TransformerConfigurationException;
045import javax.xml.transform.TransformerException;
046import javax.xml.transform.sax.TransformerHandler;
047import javax.xml.transform.stream.StreamResult;
048
049import org.apache.avalon.framework.component.Component;
050import org.apache.avalon.framework.context.ContextException;
051import org.apache.avalon.framework.context.Contextualizable;
052import org.apache.avalon.framework.service.ServiceException;
053import org.apache.avalon.framework.service.ServiceManager;
054import org.apache.avalon.framework.service.Serviceable;
055import org.apache.cocoon.Constants;
056import org.apache.cocoon.environment.Context;
057import org.apache.cocoon.xml.XMLUtils;
058import org.apache.commons.io.IOUtils;
059import org.apache.commons.lang3.StringUtils;
060import org.apache.xpath.XPathAPI;
061import org.slf4j.Logger;
062import org.w3c.dom.Document;
063import org.xml.sax.ContentHandler;
064import org.xml.sax.SAXException;
065
066import org.ametys.core.user.UserIdentity;
067import org.ametys.core.util.DateUtils;
068import org.ametys.plugins.contentio.archive.Archivers.AmetysObjectNotImportedException;
069import org.ametys.plugins.explorer.resources.Resource;
070import org.ametys.plugins.explorer.resources.ResourceCollection;
071import org.ametys.plugins.explorer.resources.jcr.JCRResource;
072import org.ametys.plugins.explorer.resources.jcr.JCRResourceFactory;
073import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollection;
074import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
075import org.ametys.plugins.repository.AmetysObject;
076import org.ametys.plugins.repository.AmetysObjectFactoryExtensionPoint;
077import org.ametys.plugins.repository.AmetysObjectIterable;
078import org.ametys.plugins.repository.AmetysRepositoryException;
079import org.ametys.plugins.repository.RepositoryConstants;
080import org.ametys.plugins.repository.dublincore.DublinCoreAwareAmetysObject;
081import org.ametys.plugins.repository.dublincore.ModifiableDublinCoreAwareAmetysObject;
082import org.ametys.plugins.repository.jcr.JCRAmetysObject;
083import org.ametys.runtime.plugin.component.AbstractLogEnabled;
084
085/**
086 * Export a resources collection as individual files.
087 */
088public class ResourcesArchiverHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
089{
090    /** Avalon role. */
091    public static final String ROLE = ResourcesArchiverHelper.class.getName();
092    
093    private static final String __PROPERTIES_METADATA_XML_FILE_NAME_SUFFIX = "properties.xml";
094    private static final String __DC_METADATA_XML_FILE_NAME_SUFFIX = "dc.xml";
095    
096    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_ROOT = "dublin-core-metadata";
097    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_TITLE = "title";
098    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_CREATOR = "creator";
099    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_SUBJECT = "subject";
100    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_DESCRIPTION = "description";
101    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_PUBLISHER = "publisher";
102    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_CONTRIBUTOR = "contributor";
103    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_DATE = "date";
104    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_TYPE = "type";
105    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_FORMAT = "format";
106    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_IDENTIFIER = "identifier";
107    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_SOURCE = "source";
108    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_LANGUAGE = "language";
109    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_RELATION = "relation";
110    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_COVERAGE = "coverage";
111    private static final String __DC_METADATA_XML_EXPORT_TAG_NAME_RIGHTS = "rights";
112    
113    private JCRResourcesCollectionFactory _jcrResourcesCollectionFactory;
114    private JCRResourceFactory _jcrResourceFactory;
115    
116    private Context _cocoonContext;
117    
118    @Override
119    public void service(ServiceManager manager) throws ServiceException
120    {
121        AmetysObjectFactoryExtensionPoint ametysObjectFactoryEP = (AmetysObjectFactoryExtensionPoint) manager.lookup(AmetysObjectFactoryExtensionPoint.ROLE);
122        _jcrResourcesCollectionFactory = (JCRResourcesCollectionFactory) ametysObjectFactoryEP.getExtension(JCRResourcesCollectionFactory.class.getName());
123        _jcrResourceFactory = (JCRResourceFactory) ametysObjectFactoryEP.getExtension(JCRResourceFactory.class.getName());
124    }
125    
126    @Override
127    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
128    {
129        _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
130    }
131    
132    /**
133     * Exports a {@link ResourceCollection} as folders and files inside the ZIP archive.
134     * @param collection the root {@link ResourceCollection}
135     * @param zos the ZIP OutputStream.
136     * @param prefix the prefix for the ZIP archive.
137     * @throws IOException if an error occurs while archiving
138     */
139    public void exportCollection(ResourceCollection collection, ZipOutputStream zos, String prefix) throws IOException
140    {
141        if (collection == null)
142        {
143            return;
144        }
145        
146        zos.putNextEntry(new ZipEntry(StringUtils.appendIfMissing(prefix, "/"))); // even if there is no child, at least export the collection folder
147        
148        try (AmetysObjectIterable<AmetysObject> objects = collection.getChildren();)
149        {
150            for (AmetysObject object : objects)
151            {
152                _exportChild(object, zos, prefix);
153            }
154        }
155        
156        try
157        {
158            // process metadata for the collection
159            _exportCollectionMetadataEntry(collection, zos, prefix);
160            // process ACL for the collection
161            Node collectionNode = ((JCRAmetysObject) collection).getNode();
162            Archivers.exportAcl(collectionNode, zos, ArchiveHandler.METADATA_PREFIX + prefix + "acl.xml");
163        }
164        catch (RepositoryException e)
165        {
166            throw new RuntimeException("Unable to SAX some information for collection '" + collection.getPath() + "' for archiving", e);
167        }
168    }
169    
170    private void _exportCollectionMetadataEntry(ResourceCollection collection, ZipOutputStream zos, String prefix) throws IOException
171    {
172        String metadataFullPrefix = ArchiveHandler.METADATA_PREFIX + prefix;
173        ZipEntry contentEntry = new ZipEntry(metadataFullPrefix + __PROPERTIES_METADATA_XML_FILE_NAME_SUFFIX);
174        zos.putNextEntry(contentEntry);
175        
176        try
177        {
178            TransformerHandler contentHandler = Archivers.newTransformerHandler();
179            contentHandler.setResult(new StreamResult(zos));
180            
181            contentHandler.startDocument();
182            _saxSystemMetadata(collection, contentHandler);
183            contentHandler.endDocument();
184        }
185        catch (SAXException | TransformerConfigurationException e)
186        {
187            throw new RuntimeException("Unable to SAX properties for collection '" + collection.getPath() + "' for archiving", e);
188        }
189    }
190    
191    private void _exportChild(AmetysObject child, ZipOutputStream zos, String prefix) throws IOException
192    {
193        if (child instanceof ResourceCollection)
194        {
195            String newPrefix = prefix + child.getName() + "/";
196            exportCollection((ResourceCollection) child, zos, newPrefix);
197        }
198        else if (child instanceof Resource)
199        {
200            exportResource((Resource) child, zos, prefix);
201        }
202    }
203    
204    /**
205     * Exports a {@link Resource} as  file inside the ZIP archive.
206     * @param resource the {@link Resource}.
207     * @param zos the ZIP OutputStream.
208     * @param prefix the prefix for the ZIP archive.
209     * @throws IOException if an error occurs while archiving
210     */
211    public void exportResource(Resource resource, ZipOutputStream zos, String prefix) throws IOException
212    {
213        ZipEntry resourceEntry = new ZipEntry(prefix + resource.getName());
214        zos.putNextEntry(resourceEntry);
215
216        try (InputStream is = resource.getInputStream())
217        {
218            IOUtils.copy(is, zos);
219        }
220        
221        // dublin core properties
222        _exportResourceMetadataEntry(resource, zos, prefix, __DC_METADATA_XML_FILE_NAME_SUFFIX, this::_saxDublinCoreMetadata, "Dublin Core metadata");
223        
224        // other properties (id, creator...)
225        _exportResourceMetadataEntry(resource, zos, prefix, __PROPERTIES_METADATA_XML_FILE_NAME_SUFFIX, this::_saxSystemMetadata, "properties");
226    }
227    
228    private void _exportResourceMetadataEntry(Resource resource, ZipOutputStream zos, String prefix, String metadataFileNameSuffix, ResourceMetadataSaxer metadataSaxer, String debugName) throws IOException
229    {
230        String metadataFullPrefix = ArchiveHandler.METADATA_PREFIX + prefix + resource.getName() + "_";
231        ZipEntry contentEntry = new ZipEntry(metadataFullPrefix + metadataFileNameSuffix);
232        zos.putNextEntry(contentEntry);
233        
234        try
235        {
236            TransformerHandler contentHandler = Archivers.newTransformerHandler();
237            contentHandler.setResult(new StreamResult(zos));
238            
239            contentHandler.startDocument();
240            metadataSaxer.sax(resource, contentHandler);
241            contentHandler.endDocument();
242        }
243        catch (SAXException | TransformerConfigurationException e)
244        {
245            throw new RuntimeException("Unable to SAX " + debugName + " for resource '" + resource.getPath() + "' for archiving", e);
246        }
247    }
248    
249    @FunctionalInterface
250    private static interface ResourceMetadataSaxer
251    {
252        void sax(Resource resource, ContentHandler contentHandler) throws SAXException;
253    }
254    
255    private void _saxDublinCoreMetadata(DublinCoreAwareAmetysObject dcObject, ContentHandler contentHandler) throws SAXException
256    {
257        XMLUtils.startElement(contentHandler, __DC_METADATA_XML_EXPORT_TAG_NAME_ROOT);
258        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_TITLE, dcObject.getDCTitle(), contentHandler);
259        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_CREATOR, dcObject.getDCCreator(), contentHandler);
260        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_SUBJECT, dcObject.getDCSubject(), contentHandler);
261        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_DESCRIPTION, dcObject.getDCDescription(), contentHandler);
262        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_PUBLISHER, dcObject.getDCPublisher(), contentHandler);
263        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_CONTRIBUTOR, dcObject.getDCContributor(), contentHandler);
264        _saxLocalDateIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_DATE, dcObject.getDCDate(), contentHandler);
265        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_TYPE, dcObject.getDCType(), contentHandler);
266        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_FORMAT, dcObject.getDCFormat(), contentHandler);
267        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_IDENTIFIER, dcObject.getDCIdentifier(), contentHandler);
268        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_SOURCE, dcObject.getDCSource(), contentHandler);
269        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_LANGUAGE, dcObject.getDCLanguage(), contentHandler);
270        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_RELATION, dcObject.getDCRelation(), contentHandler);
271        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_COVERAGE, dcObject.getDCCoverage(), contentHandler);
272        _saxIfNotNull(__DC_METADATA_XML_EXPORT_TAG_NAME_RIGHTS, dcObject.getDCRights(), contentHandler);
273        XMLUtils.endElement(contentHandler, __DC_METADATA_XML_EXPORT_TAG_NAME_ROOT);
274    }
275    
276    private void _saxSystemMetadata(Resource resource, ContentHandler contentHandler) throws SAXException
277    {
278        XMLUtils.startElement(contentHandler, "resource");
279        _saxIfNotNull("id", resource.getId(), contentHandler);
280        _saxIfNotNull("name", resource.getName(), contentHandler);
281        _saxIfNotNull("creator", resource.getCreator(), contentHandler);
282        _saxZonedDateTimeIfNotNull("creationDate", resource.getCreationDate(), contentHandler);
283        _saxIfNotNull("contributor", resource.getLastContributor(), contentHandler);
284        _saxZonedDateTimeIfNotNull("lastModified", resource.getLastModified(), contentHandler);
285        XMLUtils.endElement(contentHandler, "resource");
286    }
287    
288    private void _saxSystemMetadata(ResourceCollection collection, ContentHandler contentHandler) throws SAXException
289    {
290        XMLUtils.startElement(contentHandler, "resource-collection");
291        _saxIfNotNull("id", collection.getId(), contentHandler);
292        _saxIfNotNull("name", collection.getName(), contentHandler);
293        XMLUtils.endElement(contentHandler, "resource-collection");
294    }
295
296    private void _saxIfNotNull(String name, String value, ContentHandler contentHandler) throws SAXException
297    {
298        if (value != null)
299        {
300            XMLUtils.createElement(contentHandler, name, value);
301        }
302    }
303    
304    private void _saxIfNotNull(String name, UserIdentity value, ContentHandler contentHandler) throws SAXException
305    {
306        if (value != null)
307        {
308            XMLUtils.createElement(contentHandler, name, UserIdentity.userIdentityToString(value));
309        }
310    }
311    
312    private void _saxIfNotNull(String name, String[] values, ContentHandler contentHandler) throws SAXException
313    {
314        if (values != null)
315        {
316            for (String value : values)
317            {
318                XMLUtils.createElement(contentHandler, name, value);
319            }
320        }
321    }
322    
323    private void _saxLocalDateIfNotNull(String name, Date value, ContentHandler contentHandler) throws SAXException
324    {
325        if (value != null)
326        {
327            LocalDate ld = DateUtils.asLocalDate(value);
328            XMLUtils.createElement(contentHandler, name, ld.format(DateTimeFormatter.ISO_LOCAL_DATE));
329        }
330    }
331    
332    private void _saxZonedDateTimeIfNotNull(String name, Date value, ContentHandler contentHandler) throws SAXException
333    {
334        if (value != null)
335        {
336            ZonedDateTime zdt = DateUtils.asZonedDateTime(value, ZoneId.systemDefault());
337            XMLUtils.createElement(contentHandler, name, zdt.format(DateUtils.getISODateTimeFormatter()));
338        }
339    }
340    
341    /**
342     * Imports folders and files from the given ZIP archive and path, under the given {@link ResourceCollection}
343     * @param commonPrefix The common prefix in the ZIP archive
344     * @param parentOfRootResources the parent of the root {@link ResourceCollection} (the root will also be created)
345     * @param zipPath the input zip path
346     * @param merger The {@link Merger}
347     * @return The {@link ImportReport}
348     * @throws IOException if an error occurs while importing archive
349     */
350    public ImportReport importCollection(String commonPrefix, Node parentOfRootResources, Path zipPath, Merger merger) throws IOException
351    {
352        Importer importer;
353        List<JCRResource> importedResources;
354        try
355        {
356            importer = new Importer(commonPrefix, parentOfRootResources, zipPath, merger, getLogger());
357            importer.importRoot();
358            importedResources = importer.getImportedResource();
359        }
360        catch (ParserConfigurationException e)
361        {
362            throw new IOException(e);
363        }
364        _saveImported(parentOfRootResources);
365        _checkoutImported(importedResources);
366        return importer._report;
367    }
368    
369    private void _saveImported(Node parentOfRoot)
370    {
371        try
372        {
373            Session session = parentOfRoot.getSession();
374            if (session.hasPendingChanges())
375            {
376                getLogger().warn(Archivers.WARN_MESSAGE_ROOT_HAS_PENDING_CHANGES, parentOfRoot);
377                session.save();
378            }
379        }
380        catch (RepositoryException e)
381        {
382            throw new AmetysRepositoryException("Unable to save changes", e);
383        }
384    }
385    
386    private void _checkoutImported(List<JCRResource> importedResources)
387    {
388        for (JCRResource resource : importedResources)
389        {
390            resource.checkpoint();
391        }
392    }
393    
394    private class Importer
395    {
396        final ImportReport _report = new ImportReport();
397        private final String _commonPrefix;
398        private final Node _parentOfRoot;
399        private final Path _zipArchivePath;
400        private final Merger _merger;
401        private final Logger _logger;
402        private final DocumentBuilder _builder;
403        private final List<JCRResource> _importedResources = new ArrayList<>();
404        private JCRResourcesCollection _root;
405        private final UnitaryCollectionImporter _unitaryCollectionImporter = new UnitaryCollectionImporter();
406        private final UnitaryResourceImporter _unitaryResourceImporter = new UnitaryResourceImporter();
407        
408        Importer(String commonPrefix, Node parentOfRoot, Path zipArchivePath, Merger merger, Logger logger) throws ParserConfigurationException
409        {
410            _commonPrefix = commonPrefix;
411            _parentOfRoot = parentOfRoot;
412            _zipArchivePath = zipArchivePath;
413            _merger = merger;
414            _logger = logger;
415            _builder = DocumentBuilderFactory.newInstance()
416                    .newDocumentBuilder();
417        }
418        
419        private class UnitaryCollectionImporter implements UnitaryImporter<Node>
420        {
421            @Override
422            public String objectNameForLogs()
423            {
424                return "Resource collection";
425            }
426
427            @Override
428            public Document getPropertiesXml(Path zipEntryPath) throws Exception
429            {
430                return _getFolderPropertiesXml(zipEntryPath);
431            }
432
433            @Override
434            public String retrieveId(Document propertiesXml) throws Exception
435            {
436                return Archivers.xpathEvalNonEmpty("resource-collection/id", propertiesXml);
437            }
438
439            @Override
440            public Node create(Path zipEntryPath, String id, Document propertiesXml) throws AmetysObjectNotImportedException, Exception
441            {
442                Node node = _createResourceCollection(zipEntryPath, id, propertiesXml);
443                Archivers.unitarySave(node, _logger);
444                return node;
445            }
446            
447            @Override
448            public ImportReport getReport()
449            {
450                return _report;
451            }
452        }
453        
454        private class UnitaryResourceImporter implements UnitaryImporter<JCRResource>
455        {
456            @Override
457            public String objectNameForLogs()
458            {
459                return "Resource";
460            }
461
462            @Override
463            public Document getPropertiesXml(Path zipEntryPath) throws Exception
464            {
465                return _getFilePropertiesXml(zipEntryPath);
466            }
467
468            @Override
469            public String retrieveId(Document propertiesXml) throws Exception
470            {
471                return Archivers.xpathEvalNonEmpty("resource/id", propertiesXml);
472            }
473
474            @Override
475            public JCRResource create(Path zipEntryPath, String id, Document propertiesXml) throws AmetysObjectNotImportedException, Exception
476            {
477                JCRResource createdResource = _createdResource(zipEntryPath, id, propertiesXml);
478                Archivers.unitarySave(createdResource.getNode(), _logger);
479                return createdResource;
480            }
481            
482            @Override
483            public ImportReport getReport()
484            {
485                return _report;
486            }
487        }
488        
489        void importRoot() throws IOException
490        {
491            if (ZipEntryHelper.zipEntryFolderExists(_zipArchivePath, _commonPrefix))
492            {
493                Path rootFolderToImport = ZipEntryHelper.zipFileRoot(_zipArchivePath)
494                        .resolve(_commonPrefix);
495                
496                _importResourceCollectionAndChildren(rootFolderToImport);
497            }
498        }
499        
500        List<JCRResource> getImportedResource()
501        {
502            return _importedResources;
503        }
504        
505        private void _createResourceCollectionAcl(Node collectionNode, String folderPath) throws IOException
506        {
507            String zipEntryPath = new StringBuilder()
508                    .append(ArchiveHandler.METADATA_PREFIX)
509                    .append(StringUtils.strip(folderPath, "/"))
510                    .append("/acl.xml")
511                    .toString();
512            try
513            {
514                _logger.debug("Trying to import ACL node for ResourcesCollection '{}', from ACL XML file '{}', if it exists", collectionNode, zipEntryPath);
515                Archivers.importAcl(collectionNode, _zipArchivePath, _merger, zipEntryPath, _logger);
516            }
517            catch (RepositoryException e)
518            {
519                throw new IOException(e);
520            }
521        }
522        
523        private void _importChildren(Path folder) throws IOException
524        {
525            String pathPrefix = _relativePath(folder);
526            
527            DirectoryStream<Path> childFiles = _getDirectFileChildren(pathPrefix);
528            try (childFiles)
529            {
530                for (Path childFile : childFiles)
531                {
532                    _importResource(childFile)
533                            .ifPresent(_importedResources::add);
534                }
535            }
536            
537            DirectoryStream<Path> childFolders = _getDirectFolderChildren(pathPrefix);
538            try (childFolders)
539            {
540                for (Path childFolder : childFolders)
541                {
542                    _importResourceCollectionAndChildren(childFolder);
543                }
544            }
545        }
546        
547        private DirectoryStream<Path> _getDirectFolderChildren(String pathPrefix) throws IOException
548        {
549            return ZipEntryHelper.children(
550                _zipArchivePath, 
551                Optional.of(_commonPrefix + pathPrefix), 
552                p -> Files.isDirectory(p));
553        }
554        
555        private DirectoryStream<Path> _getDirectFileChildren(String pathPrefix) throws IOException
556        {
557            return ZipEntryHelper.children(
558                _zipArchivePath, 
559                Optional.of(_commonPrefix + pathPrefix), 
560                p -> !Files.isDirectory(p));
561        }
562        
563        private void _importResourceCollectionAndChildren(Path folder) throws IOException
564        {
565            Optional<Node> optionalCollectionNode = _importResourceCollection(folder);
566            if (optionalCollectionNode.isPresent())
567            {
568                Node collectionNode = optionalCollectionNode.get();
569                _createResourceCollectionAcl(collectionNode, folder.toString());
570                _importChildren(folder);
571            }
572        }
573        
574        private Optional<Node> _importResourceCollection(Path folder) throws ImportGlobalFailException
575        {
576            return _unitaryCollectionImporter.unitaryImport(_zipArchivePath, folder, _merger, _logger);
577        }
578        
579        private Document _getFolderPropertiesXml(Path folder) throws IOException
580        {
581            String zipEntryPath = new StringBuilder()
582                    .append(ArchiveHandler.METADATA_PREFIX)
583                    .append(StringUtils.strip(folder.toString(), "/"))
584                    .append("/")
585                    .append(__PROPERTIES_METADATA_XML_FILE_NAME_SUFFIX)
586                    .toString();
587            try (InputStream stream = ZipEntryHelper.zipEntryFileInputStream(_zipArchivePath, zipEntryPath))
588            {
589                Document doc = _builder.parse(stream);
590                return doc;
591            }
592            catch (SAXException e)
593            {
594                throw new IOException(e);
595            }
596        }
597        
598        private Node _createResourceCollection(Path folder, String id, Document propertiesXml) throws IOException, AmetysObjectNotImportedException, TransformerException
599        {
600            boolean isRoot = _isRoot(folder);
601            Node parentNode = _retrieveParentJcrNode(folder, isRoot);
602            String uuid = StringUtils.substringAfter(id, "://");
603            String collectionName = Archivers.xpathEvalNonEmpty("resource-collection/name", propertiesXml);
604            _logger.info("Creating a ResourcesCollection object for '{}' folder (id={})", folder, id);
605            
606            try
607            {
608                Node resourceCollection = _createChildResourceCollection(parentNode, uuid, collectionName);
609                if (isRoot)
610                {
611                    _root = _jcrResourcesCollectionFactory.getAmetysObject(resourceCollection, null);
612                }
613                return resourceCollection;
614            }
615            catch (RepositoryException e)
616            {
617                throw new IOException(e);
618            }
619        }
620        
621        private boolean _isRoot(Path folder)
622        {
623            String folderPath = StringUtils.strip(folder.toString(), "/");
624            String commonPrefixToCompare = StringUtils.strip(_commonPrefix, "/");
625            return commonPrefixToCompare.equals(folderPath);
626        }
627        
628        private Node _createChildResourceCollection(Node parentNode, String uuid, String collectionName) throws RepositoryException
629        {
630            // Create a Node with JCR primary type "ametys:resources-collection"
631            // But then call 'replaceNodeWithDesiredUuid' to have it with the desired UUID (srcNode will be removed)
632            Node srcNode = parentNode.addNode(collectionName, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE);
633            Node nodeWithDesiredUuid = Archivers.replaceNodeWithDesiredUuid(srcNode, uuid);
634            return nodeWithDesiredUuid;
635        }
636        
637        private String _relativePath(Path folderOrFile)
638        {
639            // for instance, _commonPrefix="resources/"
640            // relPath=folderOrFile.toString()="/resources/foo/bar"
641            // it should return "foo/bar"
642            
643            // for instance, _commonPrefix="resources/"
644            // relPath=folderOrFile.toString()="/resources"
645            // it should return ""
646            
647            String commonPrefixToRemove = "/" + StringUtils.strip(_commonPrefix, "/");
648            String relPath = folderOrFile.toString();
649            relPath = relPath.startsWith(commonPrefixToRemove)
650                    ? StringUtils.substringAfter(relPath, commonPrefixToRemove)
651                    : relPath;
652            relPath = StringUtils.strip(relPath, "/");
653            return relPath;
654        }
655        
656        private Node _retrieveParentJcrNode(Path fileOrFolder, boolean isRoot)
657        {
658            if (isRoot)
659            {
660                // is the root, thus return the parent of the root
661                return _parentOfRoot;
662            }
663            
664            if (_root == null)
665            {
666                throw new IllegalStateException("Unexpected error, the root must have been created before.");
667            }
668            
669            Path parent = fileOrFolder.getParent();
670            String parentRelPath = _relativePath(parent); 
671            return parentRelPath.isEmpty()
672                    ? _root.getNode()
673                    : _jcrResourcesCollectionFactory.<JCRResourcesCollection>getChild(_root, parentRelPath).getNode();
674        }
675        
676        private Optional<JCRResource> _importResource(Path file) throws ImportGlobalFailException
677        {
678            return _unitaryResourceImporter.unitaryImport(_zipArchivePath, file, _merger, _logger);
679        }
680        
681        private Document _getFilePropertiesXml(Path file) throws IOException
682        {
683            String zipEntryPath = new StringBuilder()
684                    .append(ArchiveHandler.METADATA_PREFIX)
685                    .append(StringUtils.strip(file.toString(), "/"))
686                    .append("_")
687                    .append(__PROPERTIES_METADATA_XML_FILE_NAME_SUFFIX)
688                    .toString();
689            try (InputStream stream = ZipEntryHelper.zipEntryFileInputStream(_zipArchivePath, zipEntryPath))
690            {
691                Document doc = _builder.parse(stream);
692                return doc;
693            }
694            catch (SAXException e)
695            {
696                throw new IOException(e);
697            }
698        }
699        
700        private JCRResource _createdResource(Path file, String id, Document propertiesXml) throws IOException, AmetysObjectNotImportedException
701        {
702            Node parentNode = _retrieveParentJcrNode(file, false);
703            String uuid = StringUtils.substringAfter(id, "://");
704            String resourceName = file.getFileName().toString();
705            _logger.info("Creating a Resource object for '{}' file (id={})", file, id);
706            
707            try
708            {
709                Node resourceNode = _createChildResource(parentNode, uuid, resourceName);
710                _setResourceData(resourceNode, file, propertiesXml);
711                _setResourceProperties(resourceNode, propertiesXml);
712                _setResourceMetadata(resourceNode, file);
713                
714                JCRResource createdResource = _resolveResource(resourceNode);
715                return createdResource;
716            }
717            catch (TransformerException | RepositoryException e)
718            {
719                throw new IOException(e);
720            }
721        }
722        
723        private Node _createChildResource(Node parentNode, String uuid, String resourceName) throws RepositoryException
724        {
725            // Create a Node with JCR primary type "ametys:resource"
726            // But then call 'replaceNodeWithDesiredUuid' to have it with the desired UUID (srcNode will be removed)
727            Node srcNode = parentNode.addNode(resourceName, "ametys:resource");
728            Node nodeWithDesiredUuid = Archivers.replaceNodeWithDesiredUuid(srcNode, uuid);
729            return nodeWithDesiredUuid;
730        }
731        
732        private JCRResource _resolveResource(Node resourceNode)
733        {
734            return _jcrResourceFactory.getAmetysObject(resourceNode, null);
735        }
736        
737        private void _setResourceData(Node resourceNode, Path file, Document propertiesXml) throws RepositoryException, IOException, TransformerException
738        {
739            Node resourceContentNode = resourceNode.addNode("jcr:content", "nt:resource");
740            
741            String mimeType = _getMimeType(file);
742            resourceContentNode.setProperty("jcr:mimeType", mimeType);
743            
744            try (InputStream stream = ZipEntryHelper.zipEntryFileInputStream(_zipArchivePath, file.toString()))
745            {
746                Binary binary = resourceNode.getSession()
747                        .getValueFactory()
748                        .createBinary(stream);
749                resourceContentNode.setProperty("jcr:data", binary);
750            }
751            
752            Date lastModified = Objects.requireNonNull(DomNodeHelper.nullableDatetimeValue(propertiesXml, "resource/lastModified"));
753            Calendar lastModifiedCal = new GregorianCalendar();
754            lastModifiedCal.setTime(lastModified);
755            resourceContentNode.setProperty("jcr:lastModified", lastModifiedCal);
756        }
757        
758        private void _setResourceProperties(Node resourceNode, Document propertiesXml) throws TransformerException, AmetysObjectNotImportedException, RepositoryException
759        {
760            UserIdentity contributor = UserIdentity.stringToUserIdentity(Archivers.xpathEvalNonEmpty("resource/contributor", propertiesXml));
761            Node lastContributorNode = resourceNode.addNode(RepositoryConstants.NAMESPACE_PREFIX + ":" + JCRResource.CONTRIBUTOR_NODE_NAME, RepositoryConstants.USER_NODETYPE);
762            lastContributorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":login", contributor.getLogin());
763            lastContributorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":population", contributor.getPopulationId());
764            
765            UserIdentity creator = UserIdentity.stringToUserIdentity(Archivers.xpathEvalNonEmpty("resource/creator", propertiesXml));
766            Node creatorNode = resourceNode.addNode(RepositoryConstants.NAMESPACE_PREFIX + ":" + JCRResource.CREATOR_NODE_NAME, RepositoryConstants.USER_NODETYPE);
767            creatorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":login", creator.getLogin());
768            creatorNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":population", creator.getPopulationId());
769            
770            Date creationDate = Objects.requireNonNull(DomNodeHelper.nullableDatetimeValue(propertiesXml, "resource/creationDate"));
771            Calendar creationDateCal = new GregorianCalendar();
772            creationDateCal.setTime(creationDate);
773            resourceNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ":" + JCRResource.CREATION_DATE, creationDateCal);
774        }
775        
776        private void _setResourceMetadata(Node resourceNode, Path file) throws IOException
777        {
778            ModifiableDublinCoreAwareAmetysObject dcObject = _jcrResourceFactory.getAmetysObject(resourceNode, null);
779            _setDublinCoreMetadata(dcObject, file);
780        }
781        
782        private void _setDublinCoreMetadata(ModifiableDublinCoreAwareAmetysObject dcObject, Path file) throws IOException
783        {
784            String zipEntryPath = new StringBuilder()
785                    .append(ArchiveHandler.METADATA_PREFIX)
786                    .append(StringUtils.strip(file.toString(), "/"))
787                    .append("_")
788                    .append(__DC_METADATA_XML_FILE_NAME_SUFFIX)
789                    .toString();
790            try (InputStream stream = ZipEntryHelper.zipEntryFileInputStream(_zipArchivePath, zipEntryPath))
791            {
792                Document doc = _builder.parse(stream);
793                _setDublinCoreMetadata(dcObject, doc);
794            }
795            catch (SAXException | TransformerException e)
796            {
797                throw new IOException(e);
798            }
799        }
800        
801        private void _setDublinCoreMetadata(ModifiableDublinCoreAwareAmetysObject dcObject, Document doc) throws TransformerException
802        {
803            org.w3c.dom.Node dcNode = XPathAPI.selectSingleNode(doc, __DC_METADATA_XML_EXPORT_TAG_NAME_ROOT);
804            dcObject.setDCTitle(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_TITLE));
805            dcObject.setDCCreator(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_CREATOR));
806            dcObject.setDCSubject(DomNodeHelper.stringValues(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_SUBJECT));
807            dcObject.setDCDescription(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_DESCRIPTION));
808            dcObject.setDCPublisher(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_PUBLISHER));
809            dcObject.setDCContributor(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_CONTRIBUTOR));
810            dcObject.setDCDate(DomNodeHelper.nullableDateValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_DATE));
811            dcObject.setDCType(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_TYPE));
812            dcObject.setDCFormat(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_FORMAT));
813            dcObject.setDCIdentifier(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_IDENTIFIER));
814            dcObject.setDCSource(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_SOURCE));
815            dcObject.setDCLanguage(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_LANGUAGE));
816            dcObject.setDCRelation(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_RELATION));
817            dcObject.setDCCoverage(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_COVERAGE));
818            dcObject.setDCRights(DomNodeHelper.nullableStringValue(dcNode, __DC_METADATA_XML_EXPORT_TAG_NAME_RIGHTS));
819        }
820        
821        private String _getMimeType(Path file)
822        {
823            return Optional.of(file)
824                    .map(Path::getFileName)
825                    .map(Path::toString)
826                    .map(String::toLowerCase)
827                    .map(_cocoonContext::getMimeType)
828                    .orElse("application/unknown");
829        }
830    }
831}