001/*
002 *  Copyright 2017 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.extraction.edition;
017
018import java.io.File;
019import java.io.IOException;
020import java.io.OutputStream;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.nio.file.Paths;
024import java.nio.file.StandardCopyOption;
025import java.util.Collections;
026import java.util.List;
027import java.util.Map;
028import java.util.Properties;
029import java.util.function.BiConsumer;
030import java.util.stream.Collectors;
031import java.util.stream.IntStream;
032import java.util.stream.Stream;
033
034import javax.xml.parsers.DocumentBuilder;
035import javax.xml.parsers.DocumentBuilderFactory;
036import javax.xml.transform.OutputKeys;
037import javax.xml.transform.Transformer;
038import javax.xml.transform.TransformerFactory;
039import javax.xml.transform.dom.DOMSource;
040import javax.xml.transform.sax.SAXTransformerFactory;
041import javax.xml.transform.sax.TransformerHandler;
042import javax.xml.transform.stream.StreamResult;
043
044import org.apache.avalon.framework.service.ServiceException;
045import org.apache.avalon.framework.service.ServiceManager;
046import org.apache.cocoon.xml.AttributesImpl;
047import org.apache.cocoon.xml.XMLUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.excalibur.source.Source;
050import org.apache.excalibur.source.SourceResolver;
051import org.apache.excalibur.source.impl.FileSource;
052import org.apache.xml.serializer.OutputPropertiesFactory;
053import org.w3c.dom.Document;
054import org.w3c.dom.Element;
055import org.w3c.dom.NodeList;
056
057import org.ametys.cms.repository.ContentDAO;
058import org.ametys.cms.workflow.ContentWorkflowHelper;
059import org.ametys.core.ui.Callable;
060import org.ametys.core.ui.StaticClientSideElement;
061import org.ametys.core.user.UserIdentity;
062import org.ametys.core.util.I18nUtils;
063import org.ametys.plugins.core.user.UserHelper;
064import org.ametys.plugins.extraction.ExtractionConstants;
065import org.ametys.plugins.extraction.ExtractionRightAssignmentContext;
066import org.ametys.plugins.extraction.execution.ExtractionDAO;
067import org.ametys.runtime.i18n.I18nizableText;
068
069/**
070 * This client site element manages a button to create an extraction definition file
071 */
072public class EditExtractionClientSideElement extends StaticClientSideElement
073{
074    /** The Avalon role name */
075    public static final String ROLE = EditExtractionClientSideElement.class.getName();
076    
077    private SourceResolver _sourceResolver;
078    private ContentWorkflowHelper _contentWorkflowHelper;
079    private I18nUtils _i18nUtils;
080    private ContentDAO _contentDAO;
081    private UserHelper _userHelper;
082    private ExtractionDAO _extractionDAO;
083    
084    @Override
085    public void service(ServiceManager serviceManager) throws ServiceException
086    {
087        super.service(serviceManager);
088        _sourceResolver = (SourceResolver) serviceManager.lookup(SourceResolver.ROLE);
089        _contentWorkflowHelper = (ContentWorkflowHelper) serviceManager.lookup(ContentWorkflowHelper.ROLE);
090        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
091        _contentDAO = (ContentDAO) serviceManager.lookup(ContentDAO.ROLE);
092        _userHelper = (UserHelper) serviceManager.lookup(UserHelper.ROLE);
093        _extractionDAO = (ExtractionDAO) serviceManager.lookup(ExtractionDAO.ROLE);
094        
095    }
096
097    /**
098     * Creates an extraction definition file.
099     * @param relativeDefinitionFilePath The path of the extraction definition file to create. This path has to be relative to the base definition directory.
100     * @param language the language used to create the description
101     * @return Map containing success boolean and the created extraction informations, or error codes if one occurs
102     * @throws Exception if an error occurs
103     */
104    @Callable (right = ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID)
105    public Map<String, Object> createExtraction(String relativeDefinitionFilePath, String language) throws Exception
106    {
107        // Create extraction definitions directory
108        Source definitionsSrc = _sourceResolver.resolveURI(ExtractionConstants.DEFINITIONS_DIR);
109        File definitionsDir = ((FileSource) definitionsSrc).getFile();
110        definitionsDir.mkdirs();
111        
112        String absoluteDefinitionFilePath = ExtractionConstants.DEFINITIONS_DIR + relativeDefinitionFilePath;
113        Source definitionSrc = _sourceResolver.resolveURI(absoluteDefinitionFilePath);
114        File definitionFile = ((FileSource) definitionSrc).getFile();
115
116        if (definitionFile.exists())
117        {
118            getLogger().error("A definition file already exists at path '{}'", relativeDefinitionFilePath);
119            return Map.of(
120                    "success", false,
121                    "error", "already-exists");
122        }
123        
124        try (OutputStream os = Files.newOutputStream(Paths.get(definitionFile.getAbsolutePath())))
125        {
126            // Create the description content
127            String extractionName = _getExtractionNameFromFileName(definitionFile.getName());
128            I18nizableText descriptionTitle = new I18nizableText(ExtractionConstants.PLUGIN_NAME, ExtractionConstants.DESCRIPTION_DEFAULT_TITLE_KEY, Collections.singletonList(extractionName));
129            
130            Map<String, Object> contentInfos = _contentWorkflowHelper.createContent(
131                    ExtractionConstants.DESCRIPTION_CONTENT_WORKFLOW_NAME,
132                    ExtractionConstants.DESCRIPTION_CONTENT_INITIAL_ACTION_ID,
133                    extractionName,
134                    _i18nUtils.translate(descriptionTitle, language),
135                    new String[] {ExtractionConstants.DESCRIPTION_CONTENT_TYPE_ID},
136                    new String[0],
137                    language
138                );
139            String descriptionId = (String) contentInfos.get("contentId");
140            
141            // Create a transformer for saving sax into a file
142            TransformerHandler handler = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
143            
144            StreamResult result = new StreamResult(os);
145            handler.setResult(result);
146
147            // create the format of result
148            Properties format = new Properties();
149            format.put(OutputKeys.METHOD, "xml");
150            format.put(OutputKeys.INDENT, "yes");
151            format.put(OutputKeys.ENCODING, "UTF-8");
152            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4");
153            handler.getTransformer().setOutputProperties(format);
154            handler.startDocument();
155
156            // sax skeleton
157            XMLUtils.startElement(handler, ExtractionConstants.EXTRACTION_TAG);
158            
159            // Generate SAX events for the extraction's description
160            AttributesImpl attributes = new AttributesImpl();
161            attributes.addCDATAAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME, descriptionId);
162            XMLUtils.createElement(handler, ExtractionConstants.DESCRIPTION_TAG, attributes);
163                        
164            // Generate SAX events for the extraction's author
165            UserIdentity author = _currentUserProvider.getUser();
166            _userHelper.saxUserIdentity(author, handler, ExtractionConstants.AUTHOR_TAG);
167            
168            XMLUtils.endElement(handler, ExtractionConstants.EXTRACTION_TAG);
169            
170            handler.endDocument();
171        
172            return Map.of(
173                    "success", true, 
174                    "path", relativeDefinitionFilePath,
175                    "name", definitionFile.getName(),
176                    "descriptionId", descriptionId,
177                    "author", _userHelper.user2json(author),
178                    "canRead", true,
179                    "canWrite", true,
180                    "canAssignRights", true);
181        }
182        catch (Exception e)
183        {
184            getLogger().error("Error when trying to create the extraction definition file '{}'", relativeDefinitionFilePath, e);
185            return Map.of(
186                    "success", false,
187                    "error", "other-error");
188        }
189    }
190    
191    private String _getExtractionNameFromFileName(String fileName)
192    {
193        return fileName.contains(".") ? fileName.substring(0, fileName.lastIndexOf(".")) : fileName;
194    }
195    
196    /**
197     * Adds a description to an extraction.
198     * @param definitionFileName The extraction definition file name
199     * @param descriptionId the identifier of the description 
200     * @return Map containing success boolean and error codes if one occurs
201     * @throws Exception if an error occurs
202     */
203    @Callable (right = ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID)
204    public Map<String, Object> addDescription(String definitionFileName, String descriptionId) throws Exception
205    {
206        return _modifyDefinitionFile(definitionFileName, descriptionId, this::_insertDescriptionInDocument);
207    }
208
209    private void _insertDescriptionInDocument(Document document, String descriptionId)
210    {
211        Element extractionRoot = document.getDocumentElement();
212        
213        // Delete the current description node if exists
214        _getElements(document.getDocumentElement(), ExtractionConstants.DESCRIPTION_TAG)
215            .forEach(extractionRoot::removeChild);
216
217        // Insert the description in the extraction root node
218        Element description = document.createElement(ExtractionConstants.DESCRIPTION_TAG);
219        description.setAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME, descriptionId);
220        extractionRoot.insertBefore(description, extractionRoot.getFirstChild());
221    }
222    
223    private <T> Map<String, Object> _modifyDefinitionFile(String definitionFileName, T dataToModify, BiConsumer<Document, T> modifyingConsumer) throws Exception
224    {
225        String definitionFilePath = ExtractionConstants.DEFINITIONS_DIR + definitionFileName;
226        Source definitionSrc = _sourceResolver.resolveURI(definitionFilePath);
227        File definitionFile = ((FileSource) definitionSrc).getFile();
228        
229        if (!definitionFile.exists())
230        {
231            getLogger().error("Error while adding a description to the extraction '{}': this definition file doesn't exist.", definitionFileName);
232            return Map.of(
233                    "success", false,
234                    "error", "unexisting");
235        }
236        
237        String tmpFilePath = ExtractionConstants.DEFINITIONS_DIR + definitionFileName + ".tmp";
238        Source tmpSrc = _sourceResolver.resolveURI(tmpFilePath);
239        File tmpFile = ((FileSource) tmpSrc).getFile();
240        
241        try (OutputStream os = Files.newOutputStream(Paths.get(tmpFile.getAbsolutePath())))
242        {
243            // Parse existing definition file
244            Document document = _parseDefinitionFile(definitionFile);
245            
246            // Apply the modification
247            modifyingConsumer.accept(document, dataToModify);
248
249            // Write the updated definition file
250            DOMSource source = new DOMSource(document);
251            Transformer transformer = TransformerFactory.newInstance().newTransformer();
252            StreamResult result = new StreamResult(os);
253            transformer.transform(source, result);
254            
255            Files.copy(tmpFile.toPath(), definitionFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
256
257            return Map.of("success", true);
258        }
259        catch (Exception e)
260        {
261            getLogger().error("Error when trying to modify the extraction '{}'", definitionFileName, e);
262            return Map.of(
263                    "success", false,
264                    "error", "other-error");
265        }
266        finally
267        {
268            // delete the temporary file to keep the original one
269            _deleteTemporaryFile(definitionFileName, tmpFile.toPath());
270        }
271    }
272    
273    private void _deleteTemporaryFile(String definitionFileName, Path temporaryFilePath)
274    {
275        try
276        {
277            Files.deleteIfExists(temporaryFilePath);
278        }
279        catch (IOException e)
280        {
281            getLogger().error("Error when deleting the temporary file for '{}'", definitionFileName, e);
282        }
283    }
284
285    /**
286     * Renames an extraction definition file.
287     * @param relativeOldFilePath The extraction definition old file path, relative to the base definitions directory
288     * @param newFileName The extraction definition new file name
289     * @return Map containing success boolean and error codes if one occurs
290     * @throws Exception if an error occurs
291     */
292    @Callable (right = ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID)
293    public Map<String, Object> renameExtraction(String relativeOldFilePath, String newFileName) throws Exception
294    {
295        String asoluteOldFilePath = ExtractionConstants.DEFINITIONS_DIR + relativeOldFilePath;
296        Source oldSrc = _sourceResolver.resolveURI(asoluteOldFilePath);
297        File oldFile = ((FileSource) oldSrc).getFile();
298        
299        if (!oldFile.exists())
300        {
301            getLogger().error("Error while renaming '{}': this definition file doesn't exist.", relativeOldFilePath);
302            return Map.of(
303                    "success", false,
304                    "error", "unexisting");
305        }
306        
307        String relativeParentPath = StringUtils.removeEnd(relativeOldFilePath, oldFile.getName());
308        String relativeNewFilePath = relativeParentPath + newFileName;
309        String absoluteNewFilePath = ExtractionConstants.DEFINITIONS_DIR + relativeNewFilePath;
310        Source newSrc = _sourceResolver.resolveURI(absoluteNewFilePath);
311        File newFile = ((FileSource) newSrc).getFile();
312        
313        if (newFile.exists())
314        {
315            getLogger().error("Error while renaming to '{}': a definition file with this name already exists.", relativeNewFilePath);
316            return Map.of(
317                    "success", false,
318                    "error", "already-exists");
319        }
320
321        try
322        {
323            String sourceContext = ExtractionRightAssignmentContext.ROOT_CONTEXT_PREFIX + "/" + relativeOldFilePath;
324            String targetContext = ExtractionRightAssignmentContext.ROOT_CONTEXT_PREFIX + "/" + relativeNewFilePath;
325            
326            _extractionDAO.copyRights(sourceContext, targetContext);
327
328            // Copy old file in the new one
329            Files.copy(oldFile.toPath(), newFile.toPath());
330            _extractionDAO.deleteRights(sourceContext);
331        }
332        catch (IOException e)
333        {
334            getLogger().error("Error while copying old definition file '{}' in the new one.", relativeOldFilePath, e);
335            return Map.of(
336                    "success", false,
337                    "error", "other-error");
338        }
339        
340        try
341        {
342            Files.deleteIfExists(oldFile.toPath());
343        }
344        catch (IOException e)
345        {
346            getLogger().error("Error while deleting old definition file '{}'", relativeOldFilePath, e);
347            return Map.of(
348                    "success", false,
349                    "error", "other-error");
350        }
351        
352        return Map.of(
353                "success", true, 
354                "path", relativeNewFilePath,
355                "name", newFileName);
356    }
357    
358    /**
359     * Deletes an extraction definition file.
360     * @param definitionFileName The extraction definition file to delete
361     * @return <code>true</code> if extraction deletion succeed, <code>false</code> otherwise
362     * @throws Exception if an error occurs
363     */
364    @Callable (right = ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID)
365    public boolean deleteExtraction(String definitionFileName) throws Exception
366    {
367        String filePath = ExtractionConstants.DEFINITIONS_DIR + definitionFileName;
368        Source source = _sourceResolver.resolveURI(filePath);
369        File file = ((FileSource) source).getFile();
370        
371        if (!file.exists())
372        {
373            throw new IllegalArgumentException("Error while deleting '" + definitionFileName + "': this definition file doesn't exist.");
374        }
375        
376        try
377        {
378            // Get description identifiers
379            Document document = _parseDefinitionFile(file);
380            List<String> descriptionIds = _getElements(document.getDocumentElement(), ExtractionConstants.DESCRIPTION_TAG)
381                .map(description -> description.getAttribute(ExtractionConstants.DESCRIPTION_IDENTIFIER_ATTRIBUTE_NAME))
382                .collect(Collectors.toList());
383            
384            // Delete descriptions contents
385            _contentDAO.deleteContents(descriptionIds, ExtractionConstants.MODIFY_EXTRACTION_RIGHT_ID);
386
387            Files.deleteIfExists(file.toPath());
388
389            String context = ExtractionRightAssignmentContext.ROOT_CONTEXT_PREFIX + "/" + definitionFileName;
390            _extractionDAO.deleteRights(context);
391        }
392        catch (IOException e)
393        {
394            if (getLogger().isErrorEnabled())
395            {
396                getLogger().error("Error while deleting definition file '" + definitionFileName + "'.", e);
397            }
398            throw e;
399        }
400        
401        return true;
402    }
403    
404    private Document _parseDefinitionFile(File definitionFile) throws Exception
405    {
406        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
407        DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
408        return documentBuilder.parse(definitionFile);
409    }
410    
411    private Stream<Element> _getElements(Element extractionRoot, String nodeTagName)
412    {
413        NodeList nodeList = extractionRoot.getElementsByTagName(nodeTagName);
414        return IntStream.range(0, nodeList.getLength())
415                .mapToObj(nodeList::item)
416                .map(Element.class::cast);
417    }
418    
419}