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}