001/*
002 *  Copyright 2024 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.workspaces.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.util.Optional;
024import java.util.zip.ZipEntry;
025import java.util.zip.ZipOutputStream;
026
027import javax.jcr.Node;
028import javax.jcr.NodeIterator;
029import javax.jcr.RepositoryException;
030import javax.jcr.Session;
031import javax.xml.parsers.DocumentBuilder;
032import javax.xml.parsers.DocumentBuilderFactory;
033import javax.xml.parsers.ParserConfigurationException;
034import javax.xml.transform.sax.TransformerHandler;
035import javax.xml.transform.stream.StreamResult;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.w3c.dom.Document;
042import org.xml.sax.SAXException;
043
044import org.ametys.plugins.contentio.archive.Archivers;
045import org.ametys.plugins.contentio.archive.Archivers.AmetysObjectNotImportedException;
046import org.ametys.plugins.contentio.archive.ImportGlobalFailException;
047import org.ametys.plugins.contentio.archive.ImportReport;
048import org.ametys.plugins.contentio.archive.Merger;
049import org.ametys.plugins.contentio.archive.ResourcesArchiverHelper;
050import org.ametys.plugins.contentio.archive.SystemViewHandler;
051import org.ametys.plugins.contentio.archive.UnitaryImporter;
052import org.ametys.plugins.contentio.archive.ZipEntryHelper;
053import org.ametys.plugins.explorer.resources.ResourceCollection;
054import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
055import org.ametys.plugins.repository.AmetysObjectResolver;
056import org.ametys.plugins.repository.RepositoryConstants;
057import org.ametys.plugins.workspaces.project.objects.Project;
058import org.ametys.runtime.plugin.component.AbstractLogEnabled;
059
060/**
061 * Helper for operations related to exporting or import project.
062 */
063public class ProjectArchiverHelper extends AbstractLogEnabled implements Component, Serviceable
064{
065    /** the Avalon role */
066    public static final String ROLE = ProjectArchiverHelper.class.getName();
067    
068    private static final String __RESOURCES_NODE_NAME = "ametys-internal:resources";
069    private static final String __PROJECTS_NODE_NAME = "projects";
070    
071    private static final String __MODULE_ARCHIVE_FILENAME = "module.xml";
072    private static final String __PROJECT_ARCHIVE_FILE = "project.xml";
073    
074    /** the ametys object resolver */
075    protected AmetysObjectResolver _resolver;
076    /** the resource archiver helper */
077    protected ResourcesArchiverHelper _resourcesArchiverHelper;
078
079    public void service(ServiceManager manager) throws ServiceException
080    {
081        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
082        _resourcesArchiverHelper = (ResourcesArchiverHelper) manager.lookup(ResourcesArchiverHelper.ROLE);
083    }
084    
085    /**
086     * Export the project to the zip archive
087     * @param project the project to export
088     * @param zos the zip output stream
089     * @param prefix the prefix use for the path of all zip entry of the project
090     * @throws IOException if an error occurred
091     */
092    public void exportProject(Project project, ZipOutputStream zos, String prefix) throws IOException
093    {
094        try
095        {
096            // Export the project data except the module resources
097            ZipEntry projectEntry = new ZipEntry(prefix + __PROJECT_ARCHIVE_FILE);
098            zos.putNextEntry(projectEntry);
099            
100            TransformerHandler handler = Archivers.newTransformerHandler();
101            handler.setResult(new StreamResult(zos));
102            
103            Node projectNode = project.getNode();
104            projectNode.getSession().exportSystemView(
105                projectNode.getPath(),
106                new SystemViewHandler(handler, name -> __RESOURCES_NODE_NAME.equals(name), __ -> false),
107                false,
108                false
109            );
110            
111            // then the module resources if any, one by one
112            if (projectNode.hasNode(__RESOURCES_NODE_NAME))
113            {
114                Node resources = projectNode.getNode(__RESOURCES_NODE_NAME);
115                String modulePrefix = prefix + resources.getName() + "/";
116                
117                NodeIterator moduleIterator = resources.getNodes();
118                while (moduleIterator.hasNext())
119                {
120                    Node moduleNode = moduleIterator.nextNode();
121                    String name = moduleNode.getName();
122                    // export documents as a file tree
123                    if ("documents".equals(name))
124                    {
125                        ResourceCollection documentRoot = _resolver.resolve(moduleNode, false);
126                        _resourcesArchiverHelper.exportCollection(documentRoot, zos, modulePrefix  + name + "/");
127                    }
128                    // serialize other module to an XML
129                    else
130                    {
131                        ZipEntry moduleEntry = new ZipEntry(modulePrefix + name + "/" + __MODULE_ARCHIVE_FILENAME);
132                        zos.putNextEntry(moduleEntry);
133                        
134                        moduleNode.getSession().exportSystemView(
135                            moduleNode.getPath(),
136                            new SystemViewHandler(handler, __ -> false, __ -> false),
137                            false,
138                            false
139                        );
140                    }
141                }
142                
143            }
144        }
145        catch (Exception e)
146        {
147            throw new RuntimeException("Unable to archive project " + project.getName(), e);
148        }
149    }
150
151    /**
152     * Import archived projects data.
153     * @param pluginNode the plugin node where the new projects node will be added
154     * @param zipPath the path to the ZIP containing the data
155     * @param baseImportProjectsPath the path in the ZIP where the projects data are located
156     * @param merger the merger
157     * @return the import report
158     * @throws IOException if an error occurs
159     */
160    public ImportReport importProjects(Node pluginNode, Path zipPath, String baseImportProjectsPath, Merger merger) throws IOException
161    {
162        ImportReport report = new ImportReport();
163        
164        if (ZipEntryHelper.zipEntryFolderExists(zipPath, baseImportProjectsPath))
165        {
166            try
167            {
168                // projects data are included in the archive
169                // start by getting the projects node in the repository
170                Node projectsNode = pluginNode.hasNode(__PROJECTS_NODE_NAME)
171                    ? pluginNode.getNode(__PROJECTS_NODE_NAME)
172                    : pluginNode.addNode(__PROJECTS_NODE_NAME, RepositoryConstants.NAMESPACE_PREFIX + ":unstructured");
173                
174                // use an importer to delegate the handling of merge
175                ProjectImporter projectImporter = new ProjectImporter(zipPath, projectsNode, merger, report);
176                
177                try (DirectoryStream<Path> projectPaths = ZipEntryHelper.children(
178                        zipPath,
179                        Optional.of(baseImportProjectsPath.toString()),
180                        Files::isDirectory))
181                {
182                    for (Path projectPath : projectPaths)
183                    {
184                        projectImporter.unitaryImport(zipPath, projectPath, merger, getLogger());
185                    }
186                }
187            }
188            catch (ParserConfigurationException e)
189            {
190                throw new IOException(e);
191            }
192            catch (RepositoryException e)
193            {
194                throw new IOException(e);
195            }
196        }
197        
198        return report;
199    }
200    
201    private Project _createProject(Path projectZipPath, Path zipPath, Node projectsNode, Merger merger) throws IOException, AmetysObjectNotImportedException
202    {
203        Path projectData = projectZipPath.resolve(__PROJECT_ARCHIVE_FILE);
204        try
205        {
206            Session session = projectsNode.getSession();
207            String parentAbsPath = projectsNode.getPath();
208            getLogger().info("XML from '{}!{}' will be imported to '{}' with implementation of merger '{}'", zipPath, projectData.toString(), parentAbsPath, merger);
209            try (InputStream in = ZipEntryHelper.zipEntryFileInputStream(zipPath, projectData.toString()))
210            {
211                merger.jcrImportXml(session, parentAbsPath, in);
212            }
213            
214            Archivers.unitarySave(projectsNode, getLogger());
215            
216            Node projectNode = projectsNode.getNode(projectZipPath.getFileName().toString());
217            
218            _importModules(projectZipPath, zipPath, merger, session, projectNode);
219            
220            return _resolver.resolve(projectNode, false);
221        }
222        catch (RepositoryException e)
223        {
224            throw new IOException(e);
225        }
226
227    }
228
229    private void _importModules(Path projectZipPath, Path zipPath, Merger merger, Session session, Node projectNode) throws IOException, RepositoryException
230    {
231        Path modulesEntryPath = projectZipPath.resolve(__RESOURCES_NODE_NAME);
232        if (ZipEntryHelper.zipEntryFolderExists(zipPath, modulesEntryPath.toString()))
233        {
234            Node modulesRoot = projectNode.hasNode(__RESOURCES_NODE_NAME)
235                    ? projectNode.getNode(__RESOURCES_NODE_NAME)
236                            : projectNode.addNode(__RESOURCES_NODE_NAME, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE);
237            
238            try (DirectoryStream<Path> moduleEntries = ZipEntryHelper.children(zipPath, Optional.of(modulesEntryPath.toString()), Files::isDirectory))
239            {
240                for (Path moduleEntryPath : moduleEntries)
241                {
242                    // File module is stored as a collection of file
243                    if ("documents".equals(moduleEntryPath.getFileName().toString()))
244                    {
245                        _resourcesArchiverHelper.importCollection(moduleEntryPath.toString() + "/", modulesRoot, zipPath, merger);
246                    }
247                    // other modules are serialized as XML
248                    else
249                    {
250                        try (InputStream moduleIn = ZipEntryHelper.zipEntryFileInputStream(zipPath, moduleEntryPath.resolve(__MODULE_ARCHIVE_FILENAME).toString()))
251                        {
252                            getLogger().info("XML from '{}!{}' will be imported to '{}' with implementation of merger '{}'", zipPath, moduleEntryPath.toString(), modulesRoot.getPath().toString(), merger);
253                            merger.jcrImportXml(session, modulesRoot.getPath(), moduleIn);
254                            session.save();
255                        }
256                    }
257                }
258            }
259        }
260    }
261    
262    /**
263     * Inner class to leverage the logic from UnitaryImporter
264     */
265    protected class ProjectImporter implements UnitaryImporter<Project>
266    {
267        private DocumentBuilder _builder;
268        private Path _zipPath;
269        private Merger _merger;
270        private Node _projectsNode;
271        private ImportReport _report;
272
273        /**
274         * Build an new importer
275         * @param zipPath the path to the archive ZIP
276         * @param projectsNode the node where the data will be imported
277         * @param merger the merger
278         * @param report the report
279         * @throws ParserConfigurationException if an error occurred
280         */
281        public ProjectImporter(Path zipPath, Node projectsNode, Merger merger, ImportReport report) throws ParserConfigurationException
282        {
283            DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
284            builderFactory.setNamespaceAware(true);
285            _builder = builderFactory
286                    .newDocumentBuilder();
287            _merger = merger;
288            _projectsNode = projectsNode;
289            _zipPath = zipPath;
290            _report = report;
291        }
292
293        public String objectNameForLogs()
294        {
295            return "Project";
296        }
297
298        public Document getPropertiesXml(Path projectPath) throws Exception
299        {
300            String propertiesPath = projectPath.resolve(__PROJECT_ARCHIVE_FILE).toString();
301            try (InputStream stream = ZipEntryHelper.zipEntryFileInputStream(_zipPath, propertiesPath))
302            {
303                Document doc = _builder.parse(stream);
304                return doc;
305            }
306            catch (SAXException e)
307            {
308                throw new IOException(e);
309            }
310        }
311
312        public String retrieveId(Document propertiesXml) throws Exception
313        {
314            return "project://" + Archivers.xpathEvalNonEmpty("sv:node/sv:property[@sv:name='jcr:uuid']/sv:value", propertiesXml);
315        }
316
317        public Project create(Path projectZipPath, String id, Document propertiesXml) throws AmetysObjectNotImportedException, ImportGlobalFailException, Exception
318        {
319            return _createProject(projectZipPath, _zipPath, _projectsNode, _merger);
320        }
321
322        public ImportReport getReport()
323        {
324            return _report;
325        }
326        
327    }
328}