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.cms.data.type;
017
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.time.ZoneId;
022import java.time.ZonedDateTime;
023import java.time.format.DateTimeFormatter;
024import java.util.Calendar;
025import java.util.GregorianCalendar;
026import java.util.LinkedHashMap;
027import java.util.Map;
028import java.util.Optional;
029import java.util.function.Function;
030import java.util.stream.Stream;
031
032import javax.xml.transform.TransformerException;
033
034import org.apache.cocoon.xml.AttributesImpl;
035import org.apache.cocoon.xml.XMLUtils;
036import org.apache.commons.io.IOUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.commons.lang3.tuple.ImmutableTriple;
039import org.apache.commons.lang3.tuple.Triple;
040import org.w3c.dom.Element;
041import org.xml.sax.ContentHandler;
042import org.xml.sax.SAXException;
043
044import org.ametys.cms.data.Binary;
045import org.ametys.cms.data.File;
046import org.ametys.cms.data.NamedResource;
047import org.ametys.cms.data.Resource;
048import org.ametys.cms.data.RichText;
049import org.ametys.core.model.type.ModelItemTypeHelper;
050import org.ametys.core.util.DateUtils;
051import org.ametys.plugins.repository.AmetysRepositoryException;
052import org.ametys.plugins.repository.RepositoryConstants;
053import org.ametys.plugins.repository.data.repositorydata.ModifiableRepositoryData;
054import org.ametys.plugins.repository.data.repositorydata.RepositoryData;
055import org.ametys.runtime.model.compare.DataChangeType;
056import org.ametys.runtime.model.compare.DataChangeTypeDetail;
057import org.ametys.runtime.model.exception.BadItemTypeException;
058
059/**
060 * Helper for resource type of elements stored in the repository
061 */
062public final class ResourceElementTypeHelper
063{
064    /** Prefix to use for resource's metadata */
065    public static final String METADATA_PREFIX = "jcr";
066    
067    /** Mime type metadata identifier */
068    public static final String MIME_TYPE_IDENTIFIER = "mimeType";
069    
070    /** Encoding metadata identifier */
071    public static final String ENCODING_IDENTIFIER = "encoding";
072    
073    /** Last modification date metadata identifier */
074    public static final String LAST_MODIFICATION_DATE_IDENTIFIER = "lastModified";
075    
076    /** Identifier of the data containing the resource's data */
077    public static final String DATA_IDENTIFIER = "data";
078    
079    /** hash metadata identifier */ 
080    public static final String HASH_IDENTIFIER = "hash";
081    
082    /** file name metadata identifier */
083    public static final String FILENAME_IDENTIFIER = "filename";
084    
085    /** Identifier of the data to check to know if the resource is empty */
086    public static final String EMPTY_RESOURCE_IDENTIFIER = "isEmpty";
087    
088    private ResourceElementTypeHelper()
089    {
090        // Empty constructor
091    }
092    
093    /**
094     * Convert the single file into a JSON object
095     * @param file the file to convert
096     * @param fileType the type of the file
097     * @return The file as JSON
098     */
099    public static Map<String, Object> singleFileToJSON(File file, String fileType)
100    {
101        Map<String, Object> fileInfos = new LinkedHashMap<>();
102        
103        Optional.ofNullable(file.getMimeType()).ifPresent(mimeType -> fileInfos.put("mime-type", mimeType));
104        Optional.ofNullable(file.getLastModificationDate()).ifPresent(lastModificationDate -> fileInfos.put("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
105
106        fileInfos.put("size", String.valueOf(file.getLength()));
107        Optional.ofNullable(file.getPath()).ifPresent(path -> fileInfos.put("path", path));
108        fileInfos.put("type", fileType);
109        Optional.ofNullable(file.getName()).ifPresent(filename -> fileInfos.put("filename", filename));
110        
111        return fileInfos;
112    }
113    
114    /**
115     * Sets the given resource's data using the given DOM element and additional data 
116     * @param resource the resource
117     * @param element the DOM element
118     * @param additionalData additional data containing the input stream
119     * @throws TransformerException if an error occurs while parsing the DOM node
120     * @throws IOException if an error occurs reading the resource's input stream
121     */
122    public static void resourceFromXML(NamedResource resource, Element element, Optional<Object> additionalData) throws TransformerException, IOException 
123    {
124        @SuppressWarnings("unchecked")
125        Map<String, InputStream> files = additionalData.isPresent() ? (Map<String, InputStream>) additionalData.get() : Map.of();
126
127        resource.setMimeType(element.getAttribute("mime-type"));
128        resource.setLastModificationDate(ZonedDateTime.parse(element.getAttribute("lastModified"), DateTimeFormatter.ISO_OFFSET_DATE_TIME));
129        
130        String filename = element.getAttribute("filename");
131        resource.setFilename(filename);
132        
133        if (files.containsKey(filename))
134        {
135            InputStream inputStream = files.get(filename);
136            resource.setInputStream(inputStream);
137        }
138    }
139    
140    /**
141     * Generates SAX events for the given single file
142     * @param contentHandler the {@link ContentHandler} that will receive the SAX events
143     * @param tagName the tag name of the SAX event to generate.
144     * @param file the single file to SAX
145     * @param fileType the type of the file
146     * @param attributes the attributes for the SAX event to generate
147     * @throws SAXException if an error occurs during the SAX events generation
148     */
149    public static void singleFileToSAX(ContentHandler contentHandler, String tagName, File file, String fileType, AttributesImpl attributes) throws SAXException
150    {
151        Optional.ofNullable(file.getMimeType()).ifPresent(mimeType -> attributes.addCDATAAttribute("mime-type", mimeType));
152        Optional.ofNullable(file.getLastModificationDate()).ifPresent(lastModificationDate -> attributes.addCDATAAttribute("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
153        
154        attributes.addCDATAAttribute("type", fileType);
155        attributes.addCDATAAttribute("size", String.valueOf(file.getLength()));
156        Optional.ofNullable(file.getPath()).ifPresent(path -> attributes.addCDATAAttribute("path", path));
157        Optional.ofNullable(file.getName()).ifPresent(filename -> attributes.addCDATAAttribute("filename", filename));
158        
159        XMLUtils.createElement(contentHandler, tagName, attributes);
160    }
161    
162    /**
163     * Checks if the given resource data is empty. A resource data is considered as empty if it has no stream data
164     * @param resourceData the resource data to check
165     * @return <code>true</code> if the resource has is empty, <code>false</code> otherwise
166     */
167    public static boolean isResourceDataEmpty(RepositoryData resourceData)
168    {
169        if (resourceData.hasValue(EMPTY_RESOURCE_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL))
170        {
171            return resourceData.getBoolean(EMPTY_RESOURCE_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL);
172        }
173        else
174        {
175            return false;
176        }
177    }
178    
179    /**
180     * Read the binary from the given repository data
181     * @param binaryData the repository data containing the binary's data
182     * @return the read binary
183     */
184    public static Binary readBinaryData(RepositoryData binaryData)
185    {
186        String hash = getStringValue(binaryData, HASH_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL);
187        Binary binary = new Binary(binaryData, hash);
188        
189        readResourceData(binaryData, binary);
190        Optional.ofNullable(getStringValue(binaryData, FILENAME_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX)).ifPresent(binary::setFilename);
191        
192        return binary;
193    }
194    
195    /**
196     * Read the resource from the given repository data
197     * @param resourceData the repository data containing the resource's data
198     * @param resource the resource to read
199     */
200    public static void readResourceData(RepositoryData resourceData, Resource resource)
201    {
202        Optional.ofNullable(getStringValue(resourceData, MIME_TYPE_IDENTIFIER, METADATA_PREFIX)).ifPresent(resource::setMimeType);
203        Optional.ofNullable(getStringValue(resourceData, ENCODING_IDENTIFIER, METADATA_PREFIX)).ifPresent(resource::setEncoding);
204        Optional.ofNullable(getDateValue(resourceData, LAST_MODIFICATION_DATE_IDENTIFIER, METADATA_PREFIX)).ifPresent(resource::setLastModificationDate);
205    }
206    
207    /**
208     * Empties the resource data with the given name. A resource data is considered as empty if it has no stream data
209     * @param parentResourceData the parent of the resource data to empty
210     * @param name the name of the resource data
211     * @param nodeType the node type of the resource data
212     */
213    public static void emptyResourceData(ModifiableRepositoryData parentResourceData, String name, String nodeType)
214    {
215        ModifiableRepositoryData resourceData = parentResourceData.hasValue(name) ? parentResourceData.getRepositoryData(name) : parentResourceData.addRepositoryData(name, nodeType);
216        
217        try (ByteArrayInputStream is = new ByteArrayInputStream(StringUtils.EMPTY.getBytes()))
218        {
219            resourceData.setValue(DATA_IDENTIFIER, is, METADATA_PREFIX);
220        }
221        catch (IOException e)
222        {
223            throw new AmetysRepositoryException(e);
224        }
225        
226        resourceData.setValue(EMPTY_RESOURCE_IDENTIFIER, true, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL);
227    }
228    
229    /**
230     * Write the resource in the given repository data
231     * @param parentData the repository data where to write the binary
232     * @param name the name of the element to write
233     * @param value the binary to write
234     */
235    public static void writeSingleBinaryValue(ModifiableRepositoryData parentData, String name, Binary value)
236    {
237        ModifiableRepositoryData binaryData;
238        if (parentData.hasValue(name))
239        {
240            binaryData = getRepositoryData(parentData, RepositoryConstants.BINARY_NODETYPE, name, RepositoryConstants.NAMESPACE_PREFIX);
241        }
242        else
243        {
244            binaryData = parentData.addRepositoryData(name, RepositoryConstants.BINARY_NODETYPE);
245        }
246        
247        if (value != null)
248        {
249            binaryData.setValue(EMPTY_RESOURCE_IDENTIFIER, false, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL);
250
251            writeResourceData(binaryData, value);
252            Optional.ofNullable(value.getFilename()).ifPresent(filename -> binaryData.setValue(FILENAME_IDENTIFIER, filename, RepositoryConstants.NAMESPACE_PREFIX));
253            
254            if (binaryData.hasValue(HASH_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL))
255            {
256                String currentHash = getStringValue(binaryData, HASH_IDENTIFIER, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL);
257                if (!currentHash.equals(value.getHash()))
258                {
259                    Optional.ofNullable(value.getHash()).ifPresent(hash -> binaryData.setValue(HASH_IDENTIFIER, hash, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL));
260                    Optional.ofNullable(value.getInputStream()).ifPresent(stream -> binaryData.setValue(DATA_IDENTIFIER, stream, METADATA_PREFIX));
261                }
262            }
263            else
264            {
265                Optional.ofNullable(value.getHash()).ifPresent(hash -> binaryData.setValue(HASH_IDENTIFIER, hash, RepositoryConstants.NAMESPACE_PREFIX_INTERNAL));
266                Optional.ofNullable(value.getInputStream()).ifPresent(stream -> binaryData.setValue(DATA_IDENTIFIER, stream, METADATA_PREFIX));
267            }
268        }
269    }
270
271    /**
272     * Write the resource in the given repository data
273     * @param resourceData the repository data where to write the resource's data
274     * @param value the resource to write
275     */
276    public static void writeResourceData(ModifiableRepositoryData resourceData, Resource value)
277    {
278        Optional.ofNullable(value.getMimeType()).ifPresent(mimeType -> resourceData.setValue(MIME_TYPE_IDENTIFIER, mimeType, METADATA_PREFIX));
279        Optional.ofNullable(value.getEncoding()).ifPresent(encoding -> resourceData.setValue(ENCODING_IDENTIFIER, encoding, METADATA_PREFIX));
280        Optional.ofNullable(value.getLastModificationDate()).ifPresent(lastModificationDate -> resourceData.setValue(LAST_MODIFICATION_DATE_IDENTIFIER, _getCalendarFromZonedDateTime(lastModificationDate), METADATA_PREFIX));
281    }
282    
283    /**
284     * Compare the given single binaries and retrieves the changes as a stream of {@link Triple}s. The {@link Triple} contains:
285     * <ul>
286     * <li>the general type of the change (added, modified or removed) as a {@link DataChangeType},</li>
287     * <li>some details about this change if possible (after or before for a date, more or less for a number, ...) as a {@link DataChangeTypeDetail}</li>
288     * <li>The data concerned by this change if not the element itself (or an empty String)</li>
289     * </ul>
290     * @param binary1 the 1st single binary
291     * @param binary2 the 2nd single binary
292     * @return the changes between the two given single binaries as a stream of {@link Triple}s. Retrieves an empty stream if there is no change
293     * @throws IOException if an error occurs while reading the binaries' data
294     */
295    public static Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> compareSingleBinaries(Binary binary1, Binary binary2) throws IOException
296    {
297        return Stream.of
298                     (
299                         // Get changes about the binary's metadata
300                         _compareSingleResourcesMetadata(binary1, binary2),
301                         
302                         // Get changes about the binary's file name
303                         ModelItemTypeHelper.compareSingleObjects(binary1.getFilename(), binary2.getFilename(), "fileName")
304                             .map(Stream::of)
305                             .orElseGet(Stream::empty),
306                         
307                         //  Get changes about the binary's content
308                         _compareSingleResourcesContent(binary1, binary2)
309                     )
310                     .flatMap(Function.identity());
311    }
312    
313    /**
314     * Compare the given single rich texts and retrieves the changes as a stream of {@link Triple}s. The {@link Triple} contains:
315     * <ul>
316     * <li>the general type of the change (added, modified or removed) as a {@link DataChangeType},</li>
317     * <li>some details about this change if possible (after or before for a date, more or less for a number, ...) as a {@link DataChangeTypeDetail}</li>
318     * <li>The data concerned by this change if not the element itself (or an empty String)</li>
319     * </ul>
320     * @param richText1 the 1st single rich text
321     * @param richText2 the 2nd single rich text
322     * @return the changes between the two given single rich texts as a stream of {@link Triple}s. Retrieves an empty stream if there is no change
323     * @throws IOException if an error occurs while reading the rich texts' data
324     */
325    public static Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> compareSingleRichTexts(RichText richText1, RichText richText2) throws IOException
326    {
327        // Note: folders are not compared, they are used for pictures and id of the pictures are in the InputStream, so if something changed, the InputStream changed.
328        return Stream.of
329                     (
330                         _compareSingleResourcesMetadata(richText1, richText2),
331                         _compareSingleResourcesContent(richText1, richText2)
332                     )
333                     .flatMap(Function.identity());
334    }
335    
336    /**
337     * Compare the metadata of the given single resources and retrieves the changes as a stream of {@link Triple}s. The {@link Triple}s contain:
338     * <ul>
339     * <li>the general type of the change (added, modified or removed) as a {@link DataChangeType},</li>
340     * <li>some details about this change if possible (after or before for a date, more or less for a number, ...) as a {@link DataChangeTypeDetail}</li>
341     * <li>The data concerned by this change if not the element itself (or an empty String)</li>
342     * </ul>
343     * @param resource1 the 1st single resource
344     * @param resource2 the 2nd single resource
345     * @return the changes between the metadata of the two given single resources as a stream of {@link Triple}s. Retrieves an empty stream if there is no change
346     * @throws IOException if an error occurs while reading the resources' data
347     */
348    protected static Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareSingleResourcesMetadata(Resource resource1, Resource resource2) throws IOException
349    {
350        return Stream.of
351                     (
352                         ModelItemTypeHelper.compareSingleObjects(resource1.getEncoding(), resource2.getEncoding(), ENCODING_IDENTIFIER),
353                         ModelItemTypeHelper.compareSingleDates(resource1.getLastModificationDate(), resource2.getLastModificationDate(), LAST_MODIFICATION_DATE_IDENTIFIER),
354                         ModelItemTypeHelper.compareSingleObjects(resource1.getMimeType(), resource2.getMimeType(), MIME_TYPE_IDENTIFIER)
355                     )
356                     .filter(Optional::isPresent)
357                     .map(Optional::get);
358    }
359    
360    /**
361     * Compare the content of the given single resources and retrieves the changes as a stream of {@link Triple}. The {@link Triple}s contain:
362     * <ul>
363     * <li>the general type of the change (added, modified or removed) as a {@link DataChangeType},</li>
364     * <li>some details about this change if possible (after or before for a date, more or less for a number, ...) as a {@link DataChangeTypeDetail}</li>
365     * <li>The data concerned by this change if not the element itself (or an empty String)</li>
366     * </ul>
367     * @param resource1 the 1st single resource
368     * @param resource2 the 2nd single resource
369     * @return the changes between the content of the two given single resources as a stream of {@link Triple}s. Retrieves an empty stream if there is no change
370     * @throws IOException if an error occurs while reading the resources' data
371     */
372    protected static Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareSingleResourcesContent(Resource resource1, Resource resource2) throws IOException
373    {
374        try (
375                InputStream is1 = resource1.getInputStream();
376                InputStream is2 = resource2.getInputStream();
377            )
378        {
379            if (!IOUtils.contentEquals(is1, is2))
380            {
381                return Stream.of(new ImmutableTriple<>(DataChangeType.MODIFIED, DataChangeTypeDetail.NONE, "inputStream"));
382            }
383            else
384            {
385                return Stream.empty();
386            }
387        }
388    }
389    
390    /**
391     * Retrieves the string value from the given repository data
392     * @param repositoryData the repository data containing the data to retrieve
393     * @param name the name of the data to retrieve
394     * @param prefix the prefix of the data to retrieve
395     * @return the string value
396     */
397    public static String getStringValue(RepositoryData repositoryData, String name, String prefix)
398    {
399        if (!repositoryData.hasValue(name, prefix))
400        {
401            return null;
402        }
403        else if (RepositoryData.STRING_REPOSITORY_DATA_TYPE.equals(repositoryData.getType(name, prefix)))
404        {
405            return repositoryData.getString(name, prefix);
406        }
407        else
408        {
409            throw new BadItemTypeException("Try to get string value from the non string data '" + prefix + ":" + name + "' on '" + repositoryData + "'");
410        }
411    }
412    
413    /**
414     * Retrieves the string values from the given repository data
415     * @param repositoryData the repository data containing the data to retrieve
416     * @param name the name of the data to retrieve
417     * @param prefix the prefix of the data to retrieve
418     * @return the string values
419     */
420    public static String[] getStringValues(RepositoryData repositoryData, String name, String prefix)
421    {
422        if (!repositoryData.hasValue(name, prefix))
423        {
424            return null;
425        }
426        else if (RepositoryData.STRING_REPOSITORY_DATA_TYPE.equals(repositoryData.getType(name, prefix)))
427        {
428            return repositoryData.getStrings(name, prefix);
429        }
430        else
431        {
432            throw new BadItemTypeException("Try to get string value from the non string data '" + prefix + ":" + name + "' on '" + repositoryData + "'");
433        }
434    }
435    
436    /**
437     * Retrieves the child repository data from the given repository data
438     * @param <T> Type of the repository data (modifiable or not)
439     * @param parentData the repository data containing the data to retrieve
440     * @param dataTypeName the type of the data to retrieve
441     * @param name the name of the data to retrieve
442     * @param prefix the prefix of the data to retrieve
443     * @return the child repository data
444     */
445    @SuppressWarnings("unchecked")
446    public static <T extends RepositoryData> T getRepositoryData(T parentData, String dataTypeName, String name, String prefix)
447    {
448        if (!parentData.hasValue(name, prefix))
449        {
450            return null;
451        }
452        else if (dataTypeName.equals(parentData.getType(name, prefix)))
453        {
454            return (T) parentData.getRepositoryData(name, prefix);
455        }
456        else
457        {
458            throw new BadItemTypeException("Try to get data of type '" + dataTypeName + "' from the non data '" + prefix + ":" + name + "' on '" + parentData + "'");
459        }
460    }
461    
462    /**
463     * Retrieves the date value from the given repository data
464     * @param repositoryData the repository data containing data to retrieve
465     * @param prefix the prefix of the data to retrieve
466     * @param name the name of the data to retrieve
467     * @return the date value
468     */
469    public static ZonedDateTime getDateValue(RepositoryData repositoryData, String name, String prefix)
470    {
471        if (!repositoryData.hasValue(name, prefix))
472        {
473            return null;
474        }
475        else if (RepositoryData.CALENDAR_REPOSITORY_DATA_TYPE.equals(repositoryData.getType(name, prefix)))
476        {
477            return DateUtils.asZonedDateTime(repositoryData.getDate(name, prefix));
478        }
479        else
480        {
481            throw new BadItemTypeException("Try to get date value from the non date data '" + prefix + ":" + name + "' on '" + repositoryData + "'");
482        }
483    }
484    
485    private static Calendar _getCalendarFromZonedDateTime(ZonedDateTime zonedDateTime)
486    {
487        ZonedDateTime dateTimeOnUTC = zonedDateTime.withZoneSameInstant(ZoneId.of("UTC"));
488        return GregorianCalendar.from(dateTimeOnUTC);
489    }
490}