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