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}