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.warn("{} 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().getValue("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().getValue("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, getSynchronizeActionId(), 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}