001/* 002 * Copyright 2018 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.contentio.synchronize; 017 018import java.io.ByteArrayInputStream; 019import java.io.IOException; 020import java.io.StringReader; 021import java.lang.reflect.Array; 022import java.nio.charset.StandardCharsets; 023import java.util.ArrayList; 024import java.util.Date; 025import java.util.HashMap; 026import java.util.HashSet; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030 031import javax.jcr.RepositoryException; 032import javax.jcr.Value; 033 034import org.apache.avalon.framework.component.Component; 035import org.apache.avalon.framework.service.ServiceException; 036import org.apache.avalon.framework.service.ServiceManager; 037import org.apache.avalon.framework.service.Serviceable; 038import org.apache.commons.io.IOUtils; 039import org.apache.commons.lang3.ArrayUtils; 040import org.slf4j.Logger; 041 042import org.ametys.cms.FilterNameHelper; 043import org.ametys.cms.content.external.ExternalizableMetadataHelper; 044import org.ametys.cms.content.external.ExternalizableMetadataProvider.ExternalizableMetadataStatus; 045import org.ametys.cms.contenttype.ContentType; 046import org.ametys.cms.contenttype.MetadataDefinition; 047import org.ametys.cms.contenttype.MetadataType; 048import org.ametys.cms.repository.Content; 049import org.ametys.cms.repository.DefaultContent; 050import org.ametys.cms.repository.ModifiableDefaultContent; 051import org.ametys.cms.repository.WorkflowAwareContent; 052import org.ametys.cms.workflow.ContentWorkflowHelper; 053import org.ametys.cms.workflow.CreateContentFunction; 054import org.ametys.core.observation.Event; 055import org.ametys.core.observation.ObservationManager; 056import org.ametys.core.user.CurrentUserProvider; 057import org.ametys.plugins.contentio.ContentImporterHelper; 058import org.ametys.plugins.repository.AmetysObjectResolver; 059import org.ametys.plugins.repository.AmetysRepositoryException; 060import org.ametys.plugins.repository.lock.LockHelper; 061import org.ametys.plugins.repository.metadata.ModifiableBinaryMetadata; 062import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata; 063import org.ametys.plugins.repository.metadata.ModifiableRichText; 064import org.ametys.plugins.workflow.AbstractWorkflowComponent; 065 066import com.google.common.base.CharMatcher; 067import com.google.common.collect.ImmutableMap; 068import com.opensymphony.workflow.InvalidActionException; 069import com.opensymphony.workflow.WorkflowException; 070 071/** 072 * Class for basics operations on SCC. 073 */ 074public class BaseSynchroComponent implements Serviceable, Component 075{ 076 /** Avalon Role */ 077 public static final String ROLE = BaseSynchroComponent.class.getName(); 078 079 /** Id of workflow action for synchronization */ 080 public static final int SYNCHRONIZE_WORKFLOW_ACTION_ID = 800; 081 082 /** The content workflow helper */ 083 protected ContentWorkflowHelper _workflowHelper; 084 085 /** The observation manager */ 086 protected ObservationManager _observationManager; 087 088 /** The current user provider */ 089 protected CurrentUserProvider _currentUserProvider; 090 091 /** The ametys object resolver */ 092 protected AmetysObjectResolver _resolver; 093 094 @Override 095 public void service(ServiceManager manager) throws ServiceException 096 { 097 _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 098 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 099 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 100 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 101 } 102 103 /** 104 * Remove the metadata if exists 105 * @param metadataHolder The metadata holder 106 * @param metadataName The name of the metadata 107 * @param synchronize <code>true</code> if the data is synchronize 108 * @return <code>true</code> if the metadata have been removed 109 */ 110 public boolean removeMetadataIfExists(ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize) 111 { 112 if (synchronize) 113 { 114 return ExternalizableMetadataHelper.removeExternalMetadataIfExists(metadataHolder, metadataName); 115 } 116 else 117 { 118 boolean hasMetadata = metadataHolder.hasMetadata(metadataName); 119 if (hasMetadata) 120 { 121 metadataHolder.removeMetadata(metadataName); 122 } 123 return hasMetadata; 124 } 125 } 126 127 /** 128 * Validates a content after import 129 * @param content The content to validate 130 * @param validationActionId Validation action ID to use for this content 131 * @param logger The logger 132 */ 133 public void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger) 134 { 135 try 136 { 137 _workflowHelper.doAction(content, validationActionId); 138 logger.info("The content '{}' ({}) has been validated after import", content.getTitle(), content.getId()); 139 } 140 catch (InvalidActionException e) 141 { 142 logger.error("The content '{}' ({}) cannot be validated after import: may miss mandatory metadata ?", content.getTitle(), content.getId(), e); 143 } 144 catch (WorkflowException e) 145 { 146 logger.error("The content '{}' ({}) cannot be validated after import", content.getTitle(), content.getId(), e); 147 } 148 } 149 150 /** 151 * Does workflow action 152 * @param content The synchronized content 153 * @param actionId Workflow action 154 * @param event Type of event 155 * @param logger The logger 156 * @return A {@link Map} with one or two {@link Boolean}, "success" tells if the operation have been done successfully, "error" tells if an error occurs during the content saving. The save can be successful but an error can occurs during the workflow update. 157 * @throws RepositoryException if an error occurs when trying to rollback pending changes in the repository. 158 */ 159 public Map<String, Boolean> applyChanges(ModifiableDefaultContent content, Integer actionId, String event, Logger logger) throws RepositoryException 160 { 161 Map<String, Boolean> resultMap = new HashMap<>(); 162 163 try 164 { 165 content.setLastModified(new Date()); 166 try 167 { 168 content.saveChanges(); 169 } 170 catch (AmetysRepositoryException e) 171 { 172 resultMap.put("error", Boolean.TRUE); 173 logger.error("An error occurred while saving changes on content '{}'.", content.getId(), e); 174 175 // Rollback pending changes 176 content.getNode().getSession().refresh(false); 177 resultMap.put("success", Boolean.FALSE); 178 return resultMap; 179 } 180 181 if (content.isLocked() && !LockHelper.isLockOwner(content, _currentUserProvider.getUser())) 182 { 183 logger.warn("Cannot apply changes because content {} is currently locked by {}", content.getTitle(), _currentUserProvider.getUser()); 184 resultMap.put("success", Boolean.TRUE); 185 return resultMap; 186 } 187 188 // Create new version 189 content.checkpoint(); 190 191 // Notify observers that the content has been modified 192 Map<String, Object> eventParams = new HashMap<>(); 193 eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content); 194 eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId()); 195 _observationManager.notify(new Event(event, _currentUserProvider.getUser(), eventParams)); 196 197 _workflowHelper.doAction(content, actionId); 198 } 199 catch (WorkflowException | InvalidActionException e) 200 { 201 resultMap.put("error", Boolean.TRUE); 202 logger.error("Unable to update workflow of content '{}' ({})", content.getTitle(), content.getId(), e); 203 } 204 205 resultMap.put("success", Boolean.TRUE); 206 return resultMap; 207 } 208 209 /** 210 * Update the invert relation by adding/removing the content to/from the old values. 211 * @param metadataToEdit Metadata holder to edit 212 * @param metadataName Metadata name to set 213 * @param content The content to add or remove 214 * @param remove <code>true</code> if we wan't to remove the content from the relation 215 * @return <code>true</code> if there are changes 216 */ 217 public boolean updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, Content content, boolean remove) 218 { 219 return updateRelation(metadataToEdit, metadataName, content.getId(), remove); 220 } 221 222 /** 223 * Update the invert relation by adding/removing the content to/from the old values. 224 * @param metadataToEdit Metadata holder to edit 225 * @param metadataName Metadata name to set 226 * @param contentId The content to add or remove 227 * @param remove <code>true</code> if we wan't to remove the content from the relation 228 * @return <code>true</code> if there are changes 229 */ 230 public boolean updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, String contentId, boolean remove) 231 { 232 String[] oldValues = metadataToEdit.getStringArray(metadataName, new String[0]); 233 String[] newValues = null; 234 235 // If we need to remove the value 236 if (remove && ArrayUtils.contains(oldValues, contentId)) 237 { 238 newValues = ArrayUtils.removeElement(oldValues, contentId); 239 } 240 // If we need to add the value 241 else if (!remove && !ArrayUtils.contains(oldValues, contentId)) 242 { 243 newValues = ArrayUtils.add(oldValues, contentId); 244 } 245 246 // If there is a change to apply 247 if (newValues != null) 248 { 249 List<Content> contents = new ArrayList<>(); 250 for (String value : newValues) 251 { 252 contents.add(_resolver.resolveById(value)); 253 } 254 255 return ExternalizableMetadataHelper.setMetadata(metadataToEdit, metadataName, contents.toArray(new Content[contents.size()])); 256 } 257 258 return false; 259 } 260 261 /** 262 * Add the current synchronizable collection as property 263 * @param content The synchronized content 264 * @param collectionId The ID of the collection 265 * @throws RepositoryException if an error occurred 266 */ 267 public void updateSCCProperty(DefaultContent content, String collectionId) throws RepositoryException 268 { 269 if (content.getNode().hasProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY)) 270 { 271 Value[] values = content.getNode().getProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY).getValues(); 272 Set<String> collectionIds = new HashSet<>(); 273 for (Value value : values) 274 { 275 collectionIds.add(value.getString()); 276 } 277 collectionIds.add(collectionId); 278 279 content.getNode().setProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY, collectionIds.toArray(new String[] {})); 280 } 281 else 282 { 283 content.getNode().setProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY, new String[] {collectionId}); 284 } 285 } 286 287 /** 288 * Creates content action with result from request 289 * @param contentType Type of the content to create 290 * @param workflowName Workflow to use for this content 291 * @param initialActionId Action ID for initialization 292 * @param lang The language 293 * @param contentTitle The content title 294 * @param contentPrefix The content prefix for the node creation 295 * @param logger The logger 296 * @return A {@link Map} with the created content in "content", and a {@link Boolean} in "error" if an error occurs. 297 */ 298 public Map<String, Object> createContentAction(String contentType, String workflowName, int initialActionId, String lang, String contentTitle, String contentPrefix, Logger logger) 299 { 300 logger.info("Creating content '{}' with the content type '{}' for language {}", contentTitle, contentType, lang); 301 302 Map<String, Object> resultMap = new HashMap<>(); 303 304 String contentName = _getContentName(contentTitle, lang, contentPrefix); 305 306 Map<String, Object> inputs = new HashMap<>(); 307 308 inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, lang); 309 inputs.put(CreateContentFunction.CONTENT_NAME_KEY, contentName); 310 inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, contentTitle); 311 inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {contentType}); 312 313 Map<String, Object> results = new HashMap<>(); 314 inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results); 315 316 try 317 { 318 Map<String, Object> workflowResult = _workflowHelper.createContent(workflowName, initialActionId, contentName, contentTitle, new String[] {contentType}, null, lang); 319 resultMap.put("content", _resolver.resolveById((String) workflowResult.get("contentId"))); 320 } 321 catch (WorkflowException e) 322 { 323 resultMap.put("error", Boolean.TRUE); 324 logger.error("Failed to initialize workflow for content " + contentTitle + " and language " + lang, e); 325 } 326 327 return resultMap; 328 } 329 330 /** 331 * Gets the content name 332 * @param title The name 333 * @param lang The lang of the content 334 * @return The content name 335 */ 336 private String _getContentName(String title, String lang, String prefix) 337 { 338 return FilterNameHelper.filterName(prefix + "-" + title + "-" + lang); 339 } 340 341 /** 342 * Fill the metadata with remove value. 343 * @param content The content to synchronize 344 * @param contentType The content type 345 * @param logicalMetadataPath The logical metadata path without the entries 346 * @param completeMetadataPath The complete metadata path from the root of the content 347 * @param remoteValue The remote value 348 * @param synchronize <code>true</code> if synchronizable 349 * @param create <code>true</code> if content is creating, false if it is updated 350 * @param logger The logger 351 * @return A {@link Map} with a {@link Boolean} in "hasChanges" value if changes has been made, and a {@link Boolean} in "error" value if an error occurs. 352 */ 353 public Map<String, Boolean> synchronizeMetadata(ModifiableDefaultContent content, ContentType contentType, String logicalMetadataPath, String completeMetadataPath, List<Object> remoteValue, boolean synchronize, boolean create, Logger logger) 354 { 355 MetadataDefinition metadataDef = contentType.getMetadataDefinitionByPath(logicalMetadataPath); 356 ModifiableCompositeMetadata metadataHolder = getMetadataHolder(content.getMetadataHolder(), completeMetadataPath); 357 String[] arrayPath = completeMetadataPath.split("/"); 358 String metadataName = arrayPath[arrayPath.length - 1]; 359 360 if (metadataDef != null) 361 { 362 if (remoteValue != null && !remoteValue.isEmpty()) 363 { 364 Object valueToSet; 365 if (metadataDef.getType().equals(MetadataType.RICH_TEXT)) 366 { 367 valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage 368 return ImmutableMap.of("hasChanges", _setRichTextMetadata(metadataHolder, metadataName, valueToSet, synchronize, content.getTitle(), logger)); 369 } 370 else if (metadataDef.getType().equals(MetadataType.BINARY)) 371 { 372 valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage 373 return ImmutableMap.of("hasChanges", _setBinaryMetadata(metadataHolder, metadataName, valueToSet, synchronize, content.getTitle(), logger)); 374 } 375 else if (metadataDef.isMultiple()) 376 { 377 valueToSet = _toTypedArray(remoteValue.get(0).getClass(), remoteValue); 378 return _setMetadata(metadataHolder, metadataName, valueToSet, synchronize, create, content.getTitle(), contentType.getId(), logger); 379 } 380 else 381 { 382 valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage 383 return _setMetadata(metadataHolder, metadataName, valueToSet, synchronize, create, content.getTitle(), contentType.getId(), logger); 384 } 385 } 386 else if (metadataDef.getDefaultValue() != null) 387 { 388 return _setMetadata(metadataHolder, metadataName, metadataDef.getDefaultValue(), synchronize, create, content.getTitle(), contentType.getId(), logger); 389 } 390 } 391 392 return ImmutableMap.of("hasChanges", DefaultContent.METADATA_TITLE.equals(metadataName) ? false : removeMetadataIfExists(metadataHolder, metadataName, synchronize)); 393 } 394 395 /** 396 * Set the richtext metadata 397 * @param metadataHolder the metadata holder 398 * @param metadataName the metadata name 399 * @param valueToSet the value to set 400 * @param synchronize true if the metadata is synchronize 401 * @param title the content title 402 * @param logger the logger 403 * @return <code>true</code> if changes were made 404 */ 405 private boolean _setRichTextMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, String title, Logger logger) 406 { 407 try 408 { 409 String docbook = ContentImporterHelper.textToDocbook(_getLinesFromValue(valueToSet)); 410 411 ByteArrayInputStream is = new ByteArrayInputStream(docbook.getBytes(StandardCharsets.UTF_8)); 412 ModifiableRichText richText = ExternalizableMetadataHelper.getRichText(metadataHolder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true); 413 richText.setInputStream(is); 414 richText.setMimeType("text/xml"); 415 richText.setLastModified(new Date()); 416 417 return true; 418 } 419 catch (IOException e) 420 { 421 logger.error("An error occured while setting the rich text value for metadata '{}' of the content '{}'", metadataName, title, e); 422 return false; 423 } 424 } 425 426 /** 427 * Set the binary metadata 428 * @param metadataHolder the metadata holder 429 * @param metadataName the metadata name 430 * @param valueToSet the value to set 431 * @param synchronize true if the metadata is synchronize 432 * @param title the content title 433 * @param logger the logger 434 * @return <code>true</code> if changes were made 435 */ 436 protected boolean _setBinaryMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, String title, Logger logger) 437 { 438 try 439 { 440 byte[] bytes = _getBytesFromValue(valueToSet); 441 442 ByteArrayInputStream is = new ByteArrayInputStream(bytes); 443 ModifiableBinaryMetadata binary = ExternalizableMetadataHelper.getBinaryMetadata(metadataHolder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true); 444 binary.setInputStream(is); 445 binary.setMimeType("application/unknown"); 446 binary.setLastModified(new Date()); 447 binary.setFilename("noname.ext"); 448 449 return true; 450 } 451 catch (IOException e) 452 { 453 logger.error("An error occured while setting the binary value for metadata '{}' of the content '{}'", metadataName, title, e); 454 return false; 455 } 456 } 457 458 /** 459 * Get the bytes array from the value to set 460 * @param valueToSet the value to set 461 * @return the bytes array 462 * @throws IOException if an error occurred 463 */ 464 private byte[] _getBytesFromValue(Object valueToSet) throws IOException 465 { 466 if (valueToSet instanceof byte[]) 467 { 468 return (byte[]) valueToSet; 469 } 470 471 return IOUtils.toByteArray(new StringReader(valueToSet.toString()), StandardCharsets.UTF_8); 472 } 473 474 /** 475 * Get the lines array from valueToSet for the richText 476 * @param valueToSet the value to set 477 * @return the lines array 478 * @throws IOException if an error occurred 479 */ 480 private String[] _getLinesFromValue(Object valueToSet) throws IOException 481 { 482 if (valueToSet instanceof byte[]) 483 { 484 String strValue = IOUtils.toString((byte[]) valueToSet, "UTF-8"); 485 return new String[] {CharMatcher.javaIsoControl().and(CharMatcher.anyOf("\r\n\t").negate()).removeFrom(strValue)}; 486 } 487 488 return new String[] {valueToSet.toString()}; 489 } 490 491 private Map<String, Boolean> _setMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, boolean forceExternalStatus, String contentTitle, String contentType, Logger logger) 492 { 493 Map<String, Boolean> resultMap = new HashMap<>(); 494 495 try 496 { 497 boolean hasChanges; 498 if (synchronize) 499 { 500 hasChanges = ExternalizableMetadataHelper.setExternalMetadata(metadataHolder, metadataName, valueToSet, forceExternalStatus); 501 } 502 else 503 { 504 hasChanges = ExternalizableMetadataHelper.setMetadata(metadataHolder, metadataName, valueToSet); 505 } 506 resultMap.put("hasChanges", hasChanges); 507 } 508 catch (UnsupportedOperationException e) 509 { 510 logger.error("An error occured during the synchronization of the field '{}' with the value '{}' in the content '{}' type of '{}'", metadataName, valueToSet.toString(), contentTitle, contentType); 511 resultMap.put("error", Boolean.TRUE); 512 } 513 return resultMap; 514 } 515 516 /** 517 * Get the metadata holder for the requested metadata path. 518 * @param parentMetadata Initial metadata 519 * @param metadataPath Metadata path from the parent 520 * @return A metadata holder 521 */ 522 public ModifiableCompositeMetadata getMetadataHolder(ModifiableCompositeMetadata parentMetadata, String metadataPath) 523 { 524 int pos = metadataPath.indexOf("/"); 525 if (pos == -1) 526 { 527 return parentMetadata; 528 } 529 else 530 { 531 return getMetadataHolder(parentMetadata.getCompositeMetadata(metadataPath.substring(0, pos), true), metadataPath.substring(pos + 1)); 532 } 533 } 534 535 // CONTENTIO-95 Multiple values are always converted as Object[] and cannot be set 536 /** 537 * This method have been build to go through the CONTENTIO-95 problem. The toArray() method returns an Object[] and it's not supported by {@link ExternalizableMetadataHelper} methods. 538 * @param <T> The type of the array 539 * @param classToCastTo The class to cast to 540 * @param list The list to transform to an array 541 * @return A typed array. 542 */ 543 @SuppressWarnings({"cast", "unchecked"}) 544 private static <T> T[] _toTypedArray(Class<?> classToCastTo, List<Object> list) 545 { 546 // Cast to Content if instance of Content instead of another type (for structures containing both Container and SubProgram) 547 if (Content.class.isAssignableFrom(classToCastTo)) 548 { 549 return (T[]) list.toArray((Content[]) Array.newInstance(Content.class, list.size())); 550 } 551 return (T[]) list.toArray((T[]) Array.newInstance(classToCastTo, list.size())); 552 } 553}