001/* 002 * Copyright 2016 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.util.ArrayList; 019import java.util.HashMap; 020import java.util.List; 021import java.util.Map; 022import java.util.stream.Collectors; 023 024import javax.jcr.RepositoryException; 025import javax.mail.MessagingException; 026 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.avalon.framework.service.Serviceable; 030import org.apache.commons.lang3.StringUtils; 031import org.slf4j.Logger; 032 033import org.ametys.cms.contenttype.ContentType; 034import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 035import org.ametys.cms.repository.Content; 036import org.ametys.cms.repository.ContentDAO; 037import org.ametys.cms.repository.ContentQueryHelper; 038import org.ametys.cms.repository.ContentTypeExpression; 039import org.ametys.cms.repository.DefaultContent; 040import org.ametys.cms.repository.LanguageExpression; 041import org.ametys.cms.repository.ModifiableDefaultContent; 042import org.ametys.cms.repository.WorkflowAwareContent; 043import org.ametys.core.observation.Event; 044import org.ametys.core.observation.ObservationManager; 045import org.ametys.core.user.CurrentUserProvider; 046import org.ametys.core.util.I18nUtils; 047import org.ametys.core.util.mail.SendMailHelper; 048import org.ametys.plugins.contentio.synchronize.expression.CollectionExpression; 049import org.ametys.plugins.repository.AmetysObjectIterable; 050import org.ametys.plugins.repository.AmetysObjectResolver; 051import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata; 052import org.ametys.plugins.repository.query.expression.AndExpression; 053import org.ametys.plugins.repository.query.expression.Expression; 054import org.ametys.plugins.repository.query.expression.Expression.Operator; 055import org.ametys.plugins.repository.query.expression.StringExpression; 056import org.ametys.runtime.config.Config; 057import org.ametys.runtime.i18n.I18nizableText; 058 059/** 060 * Abstract implementation of {@link SynchronizableContentsCollection}. 061 */ 062public abstract class AbstractSynchronizableContentsCollection extends AbstractStaticSynchronizableContentsCollection implements Serviceable 063{ 064 /** SCC unique ID field */ 065 protected static final String SCC_UNIQUE_ID = "scc$uniqueid"; 066 067 /** The i18n utils */ 068 protected I18nUtils _i18nUtils; 069 /** The current user provider */ 070 protected CurrentUserProvider _currentUserProvider; 071 /** The observation manager */ 072 protected ObservationManager _observationManager; 073 /** The content DAO */ 074 protected ContentDAO _contentDAO; 075 /** The ametys object resolver */ 076 protected AmetysObjectResolver _resolver; 077 /** The content type extension point */ 078 protected ContentTypeExtensionPoint _contentTypeEP; 079 /** The base SCC component */ 080 protected BaseSynchroComponent _synchroComponent; 081 082 /** Number of errors encountered */ 083 protected int _nbError; 084 /** True if there is a global error during synchronization */ 085 protected boolean _hasGlobalError; 086 087 /** Number of created contents */ 088 protected int _nbCreatedContents; 089 /** Number of synchronized contents */ 090 protected int _nbSynchronizedContents; 091 /** Number of unchanged contents */ 092 protected int _nbNotChangedContents; 093 /** Number of deleted contents */ 094 protected int _nbDeletedContents; 095 096 @Override 097 public void service(ServiceManager manager) throws ServiceException 098 { 099 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 100 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 101 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 102 _contentDAO = (ContentDAO) manager.lookup(ContentDAO.ROLE); 103 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 104 _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 105 _synchroComponent = (BaseSynchroComponent) manager.lookup(BaseSynchroComponent.ROLE); 106 } 107 108 @Override 109 public List<ModifiableDefaultContent> populate(Logger logger) 110 { 111 _nbCreatedContents = 0; 112 _nbSynchronizedContents = 0; 113 _nbNotChangedContents = 0; 114 _nbDeletedContents = 0; 115 _nbError = 0; 116 _hasGlobalError = false; 117 118 logger.info("Start synchronization of collection '{}'", getId()); 119 long startTime = System.currentTimeMillis(); 120 121 // Do populate 122 List<ModifiableDefaultContent> populatedContents = _internalPopulate(logger); 123 124 if (!_hasGlobalError && removalSync()) 125 { 126 // Delete old contents if source prevails 127 deleteUnexistingContents(logger); 128 } 129 130 long endTime = System.currentTimeMillis(); 131 logger.info("End synchronization of collection '{}' in {} ms", getId(), endTime - startTime); 132 logger.info("{} contents were created", _nbCreatedContents); 133 logger.info("{} contents were updated", _nbSynchronizedContents); 134 logger.info("{} contents did not changed", _nbNotChangedContents); 135 logger.info("{} contents were deleted", _nbDeletedContents); 136 137 Map<String, Object> eventParams = new HashMap<>(); 138 eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_ID, this.getId()); 139 eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_CONTENT_TYPE, this.getContentType()); 140 _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_COLLECTION_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams)); 141 142 if (_nbError > 0 && getReportMails().length() > 0) 143 { 144 try 145 { 146 logger.info("{} contents were not created/updated because of an error.", _nbError); 147 sendErrorMail(_nbError); 148 } 149 catch (MessagingException e) 150 { 151 logger.warn("Unable to send mail", e); 152 } 153 } 154 155 return populatedContents; 156 } 157 158 /** 159 * Internal implementation of {@link #populate(Logger)} 160 * @param logger The logger 161 * @return The list of created/synchronized contents 162 */ 163 protected abstract List<ModifiableDefaultContent> _internalPopulate(Logger logger); 164 165 /** 166 * Delete contents created by a previous synchronization which does not exist anymore in remote source 167 * @param logger The logger 168 */ 169 @SuppressWarnings("unchecked") 170 protected void deleteUnexistingContents(Logger logger) 171 { 172 String query = _getContentPathQuery(null, null, null); 173 AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(query); 174 175 List<Content> contentsToRemove = _getContentsToRemove(contents); 176 177 contentsToRemove.stream().forEach(content -> logger.info("The content '{}' ({}) does not exist anymore in remote source: it will be deleted if possible.", content.getTitle(), content.getId())); 178 179 logger.info("Trying to delete contents. This can take a while..."); 180 Map<String, Object> result = _contentDAO.deleteContents(contentsToRemove.stream().map(Content::getId).collect(Collectors.toList()), true); 181 logger.info("Contents deleting process ended."); 182 183 List<Map<String, Object>> deletedContents = (List<Map<String, Object>>) result.get("deleted-contents"); 184 _nbDeletedContents += deletedContents.size(); 185 186 List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) result.get("referenced-contents"); 187 if (referencedContents.size() > 0) 188 { 189 logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.get("id")).collect(Collectors.toList())); 190 } 191 192 List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents"); 193 if (lockedContents.size() > 0) 194 { 195 logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.get("id")).collect(Collectors.toList())); 196 } 197 198 List<Map<String, Object>> undeletedContents = (List<Map<String, Object>>) result.get("undeleted-contents"); 199 if (undeletedContents.size() > 0) 200 { 201 logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size()); 202 } 203 } 204 205 /** 206 * Filter the contents to remove. 207 * @param contents The list of all the available contents 208 * @return The {@link List} of {@link Content} to remove. 209 */ 210 protected abstract List<Content> _getContentsToRemove(AmetysObjectIterable<ModifiableDefaultContent> contents); 211 212 /** 213 * Sends the report mails 214 * @param nbError The number of error 215 * @throws MessagingException if a messaging error occurred 216 */ 217 protected void sendErrorMail(int nbError) throws MessagingException 218 { 219 StringBuilder recipients = new StringBuilder(); // the builder for the addresses separated by a space 220 for (String recipient : getReportMails().split("\\n")) 221 { 222 if (recipients.length() != 0) 223 { 224 recipients.append(" "); 225 } 226 recipients.append(recipient.trim()); 227 } 228 229 String sender = Config.getInstance().getValueAsString("smtp.mail.from"); 230 231 String pluginName = "plugin.contentio"; 232 List<String> params = new ArrayList<>(); 233 params.add(getId()); 234 String subject = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_SUBJECT", params)); 235 236 params.clear(); 237 params.add(String.valueOf(nbError)); 238 params.add(getId()); 239 String baseUrl = StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValueAsString("cms.url"), "index.html"), "/"); 240 params.add(baseUrl + "/_admin/index.html?uitool=uitool-admin-logs"); 241 String body = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_BODY", params)); 242 243 SendMailHelper.sendMail(subject, null, body, recipients.toString(), sender); 244 } 245 246 /** 247 * Validates a content after import 248 * @param content The content to validate 249 * @param validationActionId Validation action ID to use for this content 250 * @param logger The logger 251 */ 252 protected void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger) 253 { 254 _synchroComponent.validateContent(content, validationActionId, logger); 255 } 256 257 /** 258 * Does workflow action 259 * @param content The synchronized content 260 * @param logger The logger 261 * @return true if the content is considered as synchronized (the apply succeeded), false otherwise. 262 * @throws RepositoryException if an error occurs when trying to rollback pending changes in the repository. 263 */ 264 protected boolean applyChanges(ModifiableDefaultContent content, Logger logger) throws RepositoryException 265 { 266 return applyChanges(content, BaseSynchroComponent.SYNCHRONIZE_WORKFLOW_ACTION_ID, org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_SYNCHRONIZED, logger); 267 } 268 269 /** 270 * Does workflow action 271 * @param content The synchronized content 272 * @param actionId Workflow action 273 * @param event Type of event 274 * @param logger The logger 275 * @return true if the content is considered as synchronized (the apply succeeded), false otherwise. 276 * @throws RepositoryException if an error occurs when trying to rollback pending changes in the repository. 277 */ 278 protected boolean applyChanges(ModifiableDefaultContent content, Integer actionId, String event, Logger logger) throws RepositoryException 279 { 280 Map<String, Boolean> resultMap = _synchroComponent.applyChanges(content, actionId, event, logger); 281 282 if (resultMap.getOrDefault("error", Boolean.FALSE)) 283 { 284 _nbError++; 285 } 286 287 return resultMap.getOrDefault("success", Boolean.FALSE).booleanValue(); 288 } 289 290 /** 291 * Creates content action with result from request 292 * @param contentType Type of the content to create 293 * @param workflowName Workflow to use for this content 294 * @param initialActionId Action ID for initialization 295 * @param lang The language 296 * @param contentTitle The content title 297 * @param logger The logger 298 * @return The content id, or null of a workflow error occured 299 */ 300 protected ModifiableDefaultContent createContentAction(String contentType, String workflowName, int initialActionId, String lang, String contentTitle, Logger logger) 301 { 302 Map<String, Object> resultMap = _synchroComponent.createContentAction(contentType, workflowName, initialActionId, lang, contentTitle, getContentPrefix(), logger); 303 304 if ((boolean) resultMap.getOrDefault("error", false)) 305 { 306 _nbError++; 307 } 308 309 return (ModifiableDefaultContent) resultMap.get("content"); 310 } 311 312 /** 313 * Construct the query to retrieve the content. 314 * @param lang Lang 315 * @param idValue Synchronization value 316 * @param contentType Content type 317 * @return The {@link List} of {@link Expression} 318 */ 319 protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType) 320 { 321 List<Expression> expList = new ArrayList<>(); 322 323 expList.add(new CollectionExpression(getId())); 324 325 if (StringUtils.isNotBlank(contentType)) 326 { 327 expList.add(new ContentTypeExpression(Operator.EQ, contentType)); 328 } 329 330 if (StringUtils.isNotBlank(idValue)) 331 { 332 expList.add(new StringExpression(getIdField(), Operator.EQ, idValue)); 333 } 334 335 if (StringUtils.isNotBlank(lang)) 336 { 337 expList.add(new LanguageExpression(Operator.EQ, lang)); 338 } 339 340 return expList; 341 } 342 343 /** 344 * Construct the query to retrieve the content. 345 * @param lang Lang 346 * @param idValue Synchronization value 347 * @param contentType Content type 348 * @return The XPATH query 349 */ 350 protected String _getContentPathQuery(String lang, String idValue, String contentType) 351 { 352 List<Expression> expList = _getExpressionsList(lang, idValue, contentType); 353 AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()])); 354 return ContentQueryHelper.getContentXPathQuery(andExp); 355 } 356 357 /** 358 * Fill the metadata with remove value. 359 * @param content The content to synchronize 360 * @param contentType The content type 361 * @param logicalMetadataPath The logical metadata path without the entries 362 * @param completeMetadataPath The complete metadata path from the root of the content 363 * @param remoteValue The remote value 364 * @param synchronize <code>true</code> if synchronizable 365 * @param create <code>true</code> if content is creating, false if it is updated 366 * @param logger The logger 367 * @return <code>true</code> if changes were made 368 */ 369 protected boolean _synchronizeMetadata(ModifiableDefaultContent content, ContentType contentType, String logicalMetadataPath, String completeMetadataPath, List<Object> remoteValue, boolean synchronize, boolean create, Logger logger) 370 { 371 Map<String, Boolean> resultMap = _synchroComponent.synchronizeMetadata(content, contentType, logicalMetadataPath, completeMetadataPath, remoteValue, synchronize, create, logger); 372 373 if (resultMap.getOrDefault("error", Boolean.FALSE)) 374 { 375 _nbError++; 376 } 377 378 return resultMap.getOrDefault("hasChanges", Boolean.FALSE).booleanValue(); 379 } 380 381 /** 382 * Remove the metadata if exists 383 * @param metadataHolder The metadata holder 384 * @param metadataName The name of the metadata 385 * @param synchronize <code>true</code> if the data is synchronize 386 * @return <code>true</code> if the metadata have been removed 387 */ 388 protected boolean _removeMetadataIfExists(ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize) 389 { 390 return _synchroComponent.removeMetadataIfExists(metadataHolder, metadataName, synchronize); 391 } 392 393 /** 394 * Get the metadata holder for the requested metadata path. 395 * @param parentMetadata Initial metadata 396 * @param metadataPath Metadata path from the parent 397 * @return A metadata holder 398 */ 399 protected ModifiableCompositeMetadata _getMetadataHolder(ModifiableCompositeMetadata parentMetadata, String metadataPath) 400 { 401 return _synchroComponent.getMetadataHolder(parentMetadata, metadataPath); 402 } 403 404 /** 405 * Update the invert relation by adding the new value (Content) to the old values. 406 * @param metadataToEdit Metadata holder to edit 407 * @param metadataName Metadata name to set 408 * @param content The content to add or remove 409 * @return <code>true</code> if there are changes 410 */ 411 protected boolean _updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, Content content) 412 { 413 return _updateRelation(metadataToEdit, metadataName, content, false); 414 } 415 416 /** 417 * Update the invert relation by adding/removing the content to/from the old values. 418 * @param metadataToEdit Metadata holder to edit 419 * @param metadataName Metadata name to set 420 * @param content The content to add or remove 421 * @param remove <code>true</code> if we wan't to remove the content from the relation 422 * @return <code>true</code> if there are changes 423 */ 424 protected boolean _updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, Content content, boolean remove) 425 { 426 return _synchroComponent.updateRelation(metadataToEdit, metadataName, content, remove); 427 } 428 429 /** 430 * Add the current synchronizable collection as property 431 * @param content The synchronized content 432 * @throws RepositoryException if an error occurred 433 */ 434 protected void updateSCCProperty(DefaultContent content) throws RepositoryException 435 { 436 _synchroComponent.updateSCCProperty(content, getId()); 437 } 438 439 /** 440 * Remove empty parameters to the map 441 * @param parameters the parameters 442 * @return the map of none empty parameters 443 */ 444 protected Map<String, Object> _removeEmptyParameters(Map<String, Object> parameters) 445 { 446 Map<String, Object> searchParams = new HashMap<>(); 447 for (String parameterName : parameters.keySet()) 448 { 449 Object parameterValue = parameters.get(parameterName); 450 if (_isParamNotEmpty(parameterValue)) 451 { 452 searchParams.put(parameterName, parameterValue); 453 } 454 } 455 456 return searchParams; 457 } 458 459 /** 460 * Check if the parameter value is empty 461 * @param parameterValue the parameter value 462 * @return true if the parameter value is empty 463 */ 464 protected boolean _isParamNotEmpty(Object parameterValue) 465 { 466 return parameterValue != null && !(parameterValue instanceof String && StringUtils.isBlank((String) parameterValue)); 467 } 468}