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}