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.Collections;
020import java.util.Date;
021import java.util.HashMap;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.stream.Collectors;
026
027import javax.jcr.RepositoryException;
028import javax.jcr.Value;
029import javax.mail.MessagingException;
030
031import org.apache.avalon.framework.configuration.Configurable;
032import org.apache.avalon.framework.configuration.Configuration;
033import org.apache.avalon.framework.configuration.ConfigurationException;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang3.StringUtils;
038import org.slf4j.Logger;
039
040import org.ametys.cms.FilterNameHelper;
041import org.ametys.cms.ObservationConstants;
042import org.ametys.cms.content.external.ExternalizableMetadataHelper;
043import org.ametys.cms.contenttype.ContentType;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.contenttype.ContentTypesHelper;
046import org.ametys.cms.contenttype.MetadataDefinition;
047import org.ametys.cms.languages.Language;
048import org.ametys.cms.languages.LanguagesManager;
049import org.ametys.cms.repository.Content;
050import org.ametys.cms.repository.ContentDAO;
051import org.ametys.cms.repository.ContentQueryHelper;
052import org.ametys.cms.repository.ContentTypeExpression;
053import org.ametys.cms.repository.DefaultContent;
054import org.ametys.cms.repository.LanguageExpression;
055import org.ametys.cms.repository.ModifiableDefaultContent;
056import org.ametys.cms.repository.WorkflowAwareContent;
057import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
058import org.ametys.cms.workflow.CreateContentFunction;
059import org.ametys.core.observation.Event;
060import org.ametys.core.observation.ObservationManager;
061import org.ametys.core.user.CurrentUserProvider;
062import org.ametys.core.util.I18nUtils;
063import org.ametys.core.util.mail.SendMailHelper;
064import org.ametys.plugins.contentio.synchronize.expression.CollectionExpression;
065import org.ametys.plugins.repository.AmetysObjectIterable;
066import org.ametys.plugins.repository.AmetysObjectResolver;
067import org.ametys.plugins.repository.AmetysRepositoryException;
068import org.ametys.plugins.repository.lock.LockHelper;
069import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
070import org.ametys.plugins.repository.query.expression.AndExpression;
071import org.ametys.plugins.repository.query.expression.Expression;
072import org.ametys.plugins.repository.query.expression.Expression.Operator;
073import org.ametys.plugins.repository.query.expression.StringExpression;
074import org.ametys.plugins.workflow.AbstractWorkflowComponent;
075import org.ametys.plugins.workflow.support.WorkflowProvider;
076import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
077import org.ametys.runtime.config.Config;
078import org.ametys.runtime.i18n.I18nizableText;
079
080import com.opensymphony.workflow.InvalidActionException;
081import com.opensymphony.workflow.WorkflowException;
082import com.opensymphony.workflow.spi.Step;
083
084/**
085 * Abstract implementation of {@link SynchronizableContentsCollection}.
086 *
087 */
088public abstract class AbstractSynchronizableContentsCollection implements SynchronizableContentsCollection, Configurable, Serviceable
089{
090    /** The id */
091    protected String _id;
092    /** The label */
093    protected I18nizableText _label;
094    /** The path to the metadata holding the 'restricted' property */
095    protected String _restrictedField;
096    /** The handled content type */
097    protected String _contentType;
098    /** The id of controller */
099    protected String _modelId;
100    /** The untyped values of controller's parameters */
101    protected Map<String, Object> _modelParamValues;
102    /** True if removal sync */
103    protected boolean _removalSync;
104    /** The name of the workflow */
105    protected String _workflowName;
106    /** The id of the initial action of the workflow */
107    protected int _initialActionId;
108    /** The id of the validate action of the workflow */
109    protected int _validateActionId;
110    /** The prefix of the contents */
111    protected String _contentPrefix;
112    /** True to validate contents after import */
113    protected boolean _validateAfterImport;
114    /** The report mails */
115    protected String _reportMails;
116    /** The id of the content operator to use */
117    protected String _synchronizingContentOperator;
118    
119    /** The languges manager */
120    protected LanguagesManager _languagesManager;
121    /** The content type extension point */
122    protected ContentTypeExtensionPoint _contentTypeEP;
123    /** The workflow provider */
124    protected WorkflowProvider _workflowProvider;
125    /** The ametys object resolver */
126    protected AmetysObjectResolver _resolver;
127    /** The helper for content types */
128    protected ContentTypesHelper _contentTypesHelper;
129    /** The current user provider */
130    protected CurrentUserProvider _currentUserProvider;
131    /** The observation manager */
132    protected ObservationManager _observationManager;
133    /** The i18n utils */
134    protected I18nUtils _i18nUtils;
135    /** The extension point for Synchronizing Content Operators */
136    protected SynchronizingContentOperatorExtensionPoint _synchronizingContentOperatorEP;
137    /** The content DAO */
138    protected ContentDAO _contentDAO;
139    
140    /** Number of errors encountered */
141    protected int _nbError;
142    /** True if there is a global error during synchronization */
143    protected boolean _hasGlobalError;
144    private List<String> _handledContents;
145    private int _nbCreatedContents;
146    private int _nbSynchronizedContents;
147    private int _nbNotChangedContents;
148    private int _nbDeletedContents;
149    
150    @Override
151    public void configure(Configuration configuration) throws ConfigurationException
152    {
153        _id = configuration.getAttribute("id");
154        _label = I18nizableText.parseI18nizableText(configuration.getChild("label"), null);
155        _contentType = configuration.getChild("contentType").getValue();
156        _removalSync = configuration.getChild("removalSync").getValueAsBoolean(false);
157        _workflowName = configuration.getChild("workflowName").getValue();
158        _initialActionId = configuration.getChild("initialActionId").getValueAsInteger();
159        _validateActionId = configuration.getChild("validateActionId").getValueAsInteger();
160        _contentPrefix = configuration.getChild("contentPrefix").getValue();
161        _restrictedField = configuration.getChild("restrictedField").getValue(null);
162        _validateAfterImport = configuration.getChild("validateAfterImport").getValueAsBoolean(false);
163        _reportMails = configuration.getChild("reportMails").getValue("");
164        _synchronizingContentOperator = configuration.getChild("contentOperator").getValue();
165        _modelId = configuration.getChild("model").getAttribute("id");
166        _modelParamValues = _parseParameters(configuration.getChild("model"));
167    }
168    
169    @Override
170    public void service(ServiceManager manager) throws ServiceException
171    {
172        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
173        _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
174        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
175        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
176        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
177        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
178        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
179        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
180        _synchronizingContentOperatorEP = (SynchronizingContentOperatorExtensionPoint) manager.lookup(SynchronizingContentOperatorExtensionPoint.ROLE);
181        _contentDAO = (ContentDAO) manager.lookup(ContentDAO.ROLE);
182    }
183    
184    /**
185     * Parse parameters' values
186     * @param configuration The root configuration
187     * @return The parameters
188     * @throws ConfigurationException if an error occurred
189     */
190    protected Map<String, Object> _parseParameters(Configuration configuration) throws ConfigurationException
191    {
192        Map<String, Object> values = new LinkedHashMap<>();
193        
194        Configuration[] params = configuration.getChildren("param");
195        for (Configuration paramConfig : params)
196        {
197            values.put(paramConfig.getAttribute("name"), paramConfig.getValue(""));
198        }
199        return values;
200    }
201    
202    @Override
203    public String getId()
204    {
205        return _id;
206    }
207    
208    @Override
209    public I18nizableText getLabel()
210    {
211        return _label;
212    }
213    
214    @Override
215    public String getContentType()
216    {
217        return _contentType;
218    }
219
220    @Override
221    public String getRestrictedField()
222    {
223        return _restrictedField;
224    }
225    
226    @Override
227    public String getSynchronizeCollectionModelId()
228    {
229        return _modelId;
230    }
231
232    @Override
233    public Map<String, Object> getParameterValues()
234    {
235        return _modelParamValues;
236    }
237    
238    @Override
239    public boolean removalSync()
240    {
241        return _removalSync;
242    }
243    
244    @Override
245    public String getWorkflowName()
246    {
247        return _workflowName;
248    }
249    
250    @Override
251    public int getInitialActionId()
252    {
253        return _initialActionId;
254    }
255    
256    @Override
257    public int getValidateActionId()
258    {
259        return _validateActionId;
260    }
261    
262    @Override
263    public String getContentPrefix()
264    {
265        return _contentPrefix;
266    }
267    
268    @Override
269    public boolean validateAfterImport()
270    {
271        return _validateAfterImport;
272    }
273    
274    @Override
275    public String getReportMails()
276    {
277        return _reportMails;
278    }
279    
280    @Override
281    public String getSynchronizingContentOperator()
282    {
283        return _synchronizingContentOperator;
284    }
285    
286    @Override
287    public void populate(Logger logger)
288    {
289        _handledContents = new ArrayList<>();
290        _nbCreatedContents = 0;
291        _nbSynchronizedContents = 0;
292        _nbNotChangedContents = 0;
293        _nbDeletedContents = 0;
294        _nbError = 0;
295        _hasGlobalError = false;
296        
297        logger.info("Start synchronization of collection '{}'", _id);
298        long startTime = System.currentTimeMillis();
299        
300        // Do populate
301        _internalPopulate(logger);
302        
303        if (!_hasGlobalError && _removalSync)
304        {
305            // Delete old contents if source prevails
306            deleteUnexistingContents(logger);
307        }
308        
309        long endTime = System.currentTimeMillis();
310        logger.info("End synchronization of collection '{}' in {} ms", _id, endTime - startTime);
311        logger.info("{} contents were created", _nbCreatedContents);
312        logger.info("{} contents were updated", _nbSynchronizedContents);
313        logger.info("{} contents did not changed", _nbNotChangedContents);
314        logger.info("{} contents were deleted", _nbDeletedContents);
315        
316        Map<String, Object> eventParams = new HashMap<>();
317        eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_ID, this.getId());
318        eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_CONTENT_TYPE, this.getContentType());
319        _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_COLLECTION_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams));
320        
321        if (_nbError > 0 && _reportMails.length() > 0)
322        {
323            try
324            {
325                logger.info("{} contents were not created/updated because of an error.", _nbError);
326                sendErrorMail(_nbError);
327            }
328            catch (MessagingException e)
329            {
330                logger.warn("Unable to send mail", e);
331            }
332        }
333    }
334    
335    /**
336     * Sends the report mails
337     * @param nbError The number of error
338     * @throws MessagingException if a messaging error occurred
339     */
340    protected void sendErrorMail(int nbError) throws MessagingException
341    {
342        StringBuilder recipients = new StringBuilder(); // the builder for the addresses separated by a space
343        for (String recipient : _reportMails.split("\\n"))
344        {
345            if (recipients.length() != 0)
346            {
347                recipients.append(" ");
348            }
349            recipients.append(recipient.trim());
350        }
351        
352        String sender = Config.getInstance().getValueAsString("smtp.mail.from");
353        
354        String pluginName = "plugin.contentio";
355        List<String> params = new ArrayList<>();
356        params.add(_id);
357        String subject = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_SUBJECT", params));
358        
359        params.clear();
360        params.add(String.valueOf(nbError));
361        params.add(_id);
362        String baseUrl = StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValueAsString("cms.url"), "index.html"), "/");
363        params.add(baseUrl + "/_admin/index.html?uitool=uitool-admin-logs");
364        String body = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_BODY", params));
365        
366        SendMailHelper.sendMail(subject, null, body, recipients.toString(), sender);
367    }
368    
369    /**
370     * Internal implementation of {@link #populate(Logger)}
371     * @param logger The logger
372     */
373    protected abstract void _internalPopulate(Logger logger);
374    
375    /**
376     * Adds the given content as handled (i.e. will not be removed if {@link #_removalSync} is true) 
377     * @param id The id of the content
378     */
379    protected void _handleContent(String id)
380    {
381        _handledContents.add(id);
382    }
383    
384    /**
385     * Returns true if the given content is handled
386     * @param id The content to test
387     * @return true if the given content is handled
388     */
389    protected boolean _isHandled(String id)
390    {
391        return _handledContents.contains(id);
392    }
393    
394    /**
395     * Imports or synchronizes a content for each available language
396     * @param idValue The unique identifier of the content
397     * @param remoteValues The remote values
398     * @param logger The logger
399     */
400    protected void importContent(String idValue, Map<String, List<Object>> remoteValues, Logger logger)
401    {
402        _handleContent(idValue);
403        
404        Map<String, Language> languages = _languagesManager.getAvailableLanguages();
405        for (String lang : languages.keySet())
406        {
407            importContent(lang, idValue, remoteValues, logger);
408        }
409    }
410    
411    /**
412     * Imports or synchronizes a content for given language
413     * @param lang The language
414     * @param idValue The title of the content
415     * @param remoteValues The remote values
416     * @param logger The logger
417     */
418    protected void importContent(String lang, String idValue, Map<String, List<Object>> remoteValues, Logger logger)
419    {
420        SynchronizingContentOperator synchronizingContentOperator = _synchronizingContentOperatorEP.getExtension(_synchronizingContentOperator);
421        Map<String, List<Object>> transformedRemoteValues = synchronizingContentOperator.transform(remoteValues, logger);
422        
423        try
424        {
425            long startTime = System.currentTimeMillis();
426            
427            ContentType contentType = _contentTypeEP.getExtension(_contentType);
428            ModifiableDefaultContent content = _getContent(lang, idValue);
429            if (content == null)
430            {
431                // Create new content
432                logger.info("Start importing content '{}' for language {}", idValue, lang);
433                
434                // Content does not exist, create it
435                String contentTitle = idValue;
436                if (transformedRemoteValues.containsKey("title"))
437                {
438                    List<Object> remoteTitles = transformedRemoteValues.get("title");
439                    contentTitle = (String) remoteTitles.stream().filter(obj -> obj instanceof String && StringUtils.isNotEmpty((String) obj)).findFirst().orElse(idValue);
440                }
441                
442                String contentId = createContentAction(lang, contentTitle, logger);
443                if (contentId != null)
444                {
445                    content = _resolver.resolveById(contentId);
446                    
447                    // Synchronize content metadata
448                    synchronizeContent(transformedRemoteValues, contentType, content, true, logger);
449                    updateSCCProperty(content);
450                    
451                    content.saveChanges();
452                    content.checkpoint();
453                    
454                    // Validate content if allowed
455                    validateContent(content, logger);
456                    
457                    _nbCreatedContents++;
458                    
459                    // Notify a content was imported
460                    Map<String, Object> eventParams = new HashMap<>();
461                    eventParams.put(ObservationConstants.ARGS_CONTENT, content);
462                    eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
463                    _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams));
464                    
465                    long endTime = System.currentTimeMillis();
466                    logger.info("End import of content '{}' for language {} in {} ms", contentId, lang, endTime - startTime);
467                }
468                
469            }
470            else
471            {
472                // Update content
473                logger.info("Start synchronizing content '{}' for language {}", content.getTitle(), lang);
474                
475                _ensureTitleIsPresent(content, transformedRemoteValues, logger);
476                
477                if (synchronizeContent(transformedRemoteValues, contentType, content, false, logger))
478                {
479                    boolean success = applyChanges(content, logger);
480                    if (success)
481                    {
482                        _nbSynchronizedContents++;
483                        logger.info("Some changes were detected for content '{}' and language {}", content.getTitle(), lang);
484                    }
485                }
486                else
487                {
488                    _nbNotChangedContents++;
489                    logger.info("No changes detected for content '{}' and language {}", content.getTitle(), lang);
490                }
491                
492                long endTime = System.currentTimeMillis();
493                logger.info("End synchronization of content '{}' for language {} in {} ms", content.getTitle(), lang, endTime - startTime);
494            }
495            
496            if (content != null)
497            {
498                // Do additional operation on the content
499                synchronizingContentOperator.additionalOperation(content, transformedRemoteValues, logger);
500            }
501            
502        }
503        catch (Exception e)
504        {
505            _nbError++;
506            logger.error("An error occurred while importing or synchronizing content", e);
507        }
508    }
509    
510    private void _ensureTitleIsPresent(Content content, Map<String, List<Object>> remoteValues, Logger logger)
511    {
512        if (remoteValues.containsKey("title"))
513        {
514            List<Object> titleValues = remoteValues.get("title");
515            boolean atLeastOneTitle = titleValues.stream()
516                                                 .filter(String.class::isInstance)
517                                                 .map(String.class::cast)
518                                                 .anyMatch(StringUtils::isNotBlank);
519            if (atLeastOneTitle)
520            {
521                return;
522            }
523        }
524        
525        // Force to current title
526        logger.warn("The remote value of 'title' is empty for the content {}. The 'title' metadata is mandatory, the current title will remain.", content);
527        remoteValues.put("title", Collections.singletonList(content.getTitle()));
528    }
529    
530    /**
531     * Add the current synchronizable collection as property
532     * @param content The synchronized content
533     * @throws RepositoryException if an error occurred
534     */
535    protected void updateSCCProperty (DefaultContent content) throws RepositoryException
536    {
537        if (content.getNode().hasProperty(COLLECTION_ID_PROPERTY))
538        {
539            Value[] values = content.getNode().getProperty(COLLECTION_ID_PROPERTY).getValues();
540            List<String> collectionIds = new ArrayList<>();
541            for (Value value : values)
542            {
543                collectionIds.add(value.getString());
544            }
545            collectionIds.add(getId());
546            
547            content.getNode().setProperty(COLLECTION_ID_PROPERTY, collectionIds.toArray(new String[] {}));
548        }
549        else
550        {
551            content.getNode().setProperty(COLLECTION_ID_PROPERTY, new String[] {getId()});
552        }
553    }
554    
555    /**
556     * Gets the content in the repository
557     * @param lang the language
558     * @param idValue the content name
559     * @return the content in the repository, or null if does not exist
560     */
561    protected ModifiableDefaultContent _getContent(String lang, String idValue)
562    {
563        String query = _getContentPathQuery(lang, idValue, _contentType);
564        AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(query);
565        
566        if (contents.getSize() > 0)
567        {
568            return contents.iterator().next();
569        }
570        return null;
571    }
572    
573    private String _getContentPathQuery(String lang, String idValue, String contentType)
574    {
575        List<Expression> expList = new ArrayList<>();
576        
577        CollectionExpression collectionExpr = new CollectionExpression(_id);
578        expList.add(collectionExpr);
579        
580        if (StringUtils.isNotBlank(contentType))
581        {
582            Expression cTypeExpr = new ContentTypeExpression(Operator.EQ, contentType);
583            expList.add(cTypeExpr);
584        }
585        
586        if (StringUtils.isNotBlank(idValue))
587        {
588            StringExpression stringExp = new StringExpression(getIdField(), Operator.EQ, idValue);
589            expList.add(stringExp);
590        }
591        
592        if (StringUtils.isNotBlank(lang))
593        {
594            LanguageExpression langExp = new LanguageExpression(Operator.EQ, lang);
595            expList.add(langExp);
596        }
597        
598        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
599        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
600        
601        return xPathQuery;
602    }
603    
604    /**
605     * Creates content action with result from request
606     * @param lang The language
607     * @param contentTitle The content title
608     * @param logger The logger
609     * @return The content id, or null of a workflow error occured
610     */
611    protected String createContentAction(String lang, String contentTitle, Logger logger)
612    {
613        String contentName = _getContentName(contentTitle, lang);
614        logger.info("Creating content '{}' for language {}", contentName, lang);
615        
616        Map<String, Object> inputs = new HashMap<>();
617        
618        inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, lang);
619        inputs.put(CreateContentFunction.CONTENT_NAME_KEY, contentName); 
620        inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, contentTitle);
621        inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {_contentType});
622        
623        Map<String, Object> results = new HashMap<>();
624        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
625        
626        try
627        {
628            _workflowProvider.getAmetysObjectWorkflow().initialize(_workflowName, _initialActionId, inputs);
629            @SuppressWarnings("unchecked")
630            Map<String, Object> workflowResult = (Map<String, Object>) inputs.get(AbstractWorkflowComponent.RESULT_MAP_KEY);
631            return (String) workflowResult.get("contentId");
632        }
633        catch (WorkflowException e)
634        {
635            _nbError++;
636            logger.error("Failed to initialize workflow for content " + contentTitle + " and language " + lang, e);
637            return null;
638        }
639    }
640    
641    /**
642     * Gets the content name
643     * @param title The name
644     * @param lang The lang of the content
645     * @return The content name
646     */
647    protected String _getContentName(String title, String lang)
648    {
649        return FilterNameHelper.filterName(_contentPrefix + "-" + title + "-" + lang);
650    }
651    
652    /**
653     * Synchronizes content
654     * @param remoteValues The remote values
655     * @param contentType The content type
656     * @param content The content to synchronize
657     * @param create true if content is creating, false if it is updated
658     * @param logger The logger
659     * @return true if changes were made
660     */
661    protected boolean synchronizeContent(Map<String, List<Object>> remoteValues, ContentType contentType, ModifiableDefaultContent content, boolean create, Logger logger)
662    {
663        boolean hasChanges = false;
664        
665        if (content.isLocked())
666        {
667            logger.warn("The content '{}' ({}) is currently locked by user {}: it cannot be synchronized", content.getTitle(), content.getId(), content.getLockOwner());
668        }
669        else
670        {
671            for (String metadataPath : remoteValues.keySet())
672            {
673                boolean synchronize = getLocalAndExternalFields().contains(metadataPath);
674                
675                MetadataDefinition metadataDef = _contentTypesHelper.getMetadataDefinitionByMetadataValuePath(metadataPath, new String[] {contentType.getId()}, new String[] {});
676                List<Object> remoteValue = remoteValues.get(metadataPath);
677                
678                ModifiableCompositeMetadata metadataHolder = _getMetadataHolder(content.getMetadataHolder(), metadataPath);
679                String[] arrayPath = metadataPath.split("/");
680                String metadataName = arrayPath[arrayPath.length - 1];
681                
682                if (metadataDef != null && remoteValue != null && !remoteValue.isEmpty())
683                {
684                    Object valueToSet;
685                    if (metadataDef.isMultiple())
686                    {
687                        valueToSet = remoteValue.toArray();
688                    }
689                    else
690                    {
691                        valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage
692                    }
693                    
694                    hasChanges = _setMetadata(metadataHolder, metadataName, valueToSet, synchronize, create) || hasChanges;
695                }
696                else if (metadataDef != null && metadataDef.getDefaultValue() != null)
697                {
698                    hasChanges = _setMetadata(metadataHolder, metadataName, metadataDef.getDefaultValue(), synchronize, create) || hasChanges;
699                }
700                else
701                {
702                    hasChanges = _removeMetadataIfExists(metadataHolder, metadataName, synchronize) || hasChanges;
703                }
704            }
705        }
706        
707        return hasChanges;
708    }
709    
710    private boolean _removeMetadataIfExists(ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize)
711    {
712        if (synchronize)
713        {
714            return ExternalizableMetadataHelper.removeExternalMetadataIfExists(metadataHolder, metadataName);
715        }
716        else
717        {
718            boolean hasMetadata = metadataHolder.hasMetadata(metadataName);
719            if (hasMetadata)
720            {
721                metadataHolder.removeMetadata(metadataName);
722            }
723            return hasMetadata;
724        }
725    }
726    
727    private ModifiableCompositeMetadata _getMetadataHolder(ModifiableCompositeMetadata parentMetadata, String metadataPath)
728    {
729        int pos = metadataPath.indexOf("/");
730        if (pos == -1)
731        {
732            return parentMetadata;
733        }
734        else
735        {
736            return _getMetadataHolder(parentMetadata.getCompositeMetadata(metadataPath.substring(0, pos), true), metadataPath.substring(pos + 1));
737        }
738    }
739    
740    private boolean _setMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, boolean forceExternalStatus)
741    {
742        if (synchronize)
743        {
744            return ExternalizableMetadataHelper.setExternalMetadata(metadataHolder, metadataName, valueToSet, forceExternalStatus);
745        }
746        else
747        {
748            return ExternalizableMetadataHelper.setMetadata(metadataHolder, metadataName, valueToSet);
749        }
750    }
751    
752    /**
753     * Validates a content after import
754     * @param content The content to validate
755     * @param logger The logger
756     */
757    protected void validateContent(WorkflowAwareContent content, Logger logger)
758    {
759        if (!_validateAfterImport)
760        {
761            // Direct validation is not allowed
762            return;
763        }
764        
765        Map<String, Object> inputs = new HashMap<>();
766        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<>());
767        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
768        // inputs.put(ValidateSynchronizedContentFunction.SILENTLY, true);
769        
770        try
771        {
772            _workflowProvider.getAmetysObjectWorkflow(content).doAction(content.getWorkflowId(), _validateActionId, inputs);
773            logger.info("The content {} ({}) has been validated after import", content.getTitle(), content.getId());
774        }
775        catch (InvalidActionException e)
776        {
777            logger.error(String.format("The content %s (%s) cannot be validated after import: may miss mandatory metadata ?", content.getTitle(), content.getId()), e);
778        }
779        catch (WorkflowException e)
780        {
781            logger.error(String.format("The content %s (%s) cannot be validated after import", content.getTitle(), content.getId()), e);
782        }
783    }
784    
785    /**
786     * Does workflow action
787     * @param content The synchronized content
788     * @param logger The logger
789     * @return true if the content is considered as synchronized (the apply succeeded), false otherwise.
790     * @throws RepositoryException if an error occurs when trying to rollback pending changes in the repository.
791     */
792    protected boolean applyChanges(ModifiableDefaultContent content, Logger logger) throws RepositoryException
793    {
794        try
795        {
796            content.setLastModified(new Date());
797            try
798            {
799                content.saveChanges();
800            }
801            catch (AmetysRepositoryException e)
802            {
803                _nbError++;
804                logger.error(String.format("An error occurred while saving changes on content '%s'.", content.getId()), e);
805                
806                // Rollback pending changes
807                content.getNode().getSession().refresh(false);
808                return false;
809            }
810            
811            // Create new version
812            content.checkpoint();
813            
814            // Notify observers that the content has been modified
815            Map<String, Object> eventParams = new HashMap<>();
816            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
817            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
818            _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams));
819        
820            if (content.isLocked() && !LockHelper.isLockOwner(content, _currentUserProvider.getUser()))
821            {
822                logger.warn("Cannot apply changes because content {} is currently locked by ", content.getTitle(), _currentUserProvider.getUser());
823                return true;
824            }
825            
826            Map<String, Object> inputs = new HashMap<>();
827            inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<>());
828            inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
829            
830            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
831            Step currentStep = (Step) workflow.getCurrentSteps(content.getWorkflowId()).get(0);
832            int currentStepId = currentStep.getStepId();
833            
834            int actionId = 800 + currentStepId * 10; // 810, 820, 830, etc.
835            workflow.doAction(content.getWorkflowId(), actionId, inputs);
836        }
837        catch (WorkflowException e)
838        {
839            _nbError++;
840            logger.error(String.format("Unable to update workflow of content %s (%s)", content.getTitle(), content.getId()), e);
841        }
842        
843        return true;
844    }
845    
846    /**
847     * Delete contents created by a previous synchronization which does not exist anymore in remote source
848     * @param logger The logger
849     */
850    @SuppressWarnings("unchecked")
851    protected void deleteUnexistingContents(Logger logger)
852    {
853        String query = _getContentPathQuery(null, null, null);
854        AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(query);
855        
856        List<Content> contentsToRemove = contents.stream()
857                .filter(content -> !_isHandled(_getIdFieldValue(content)))
858                .collect(Collectors.toList());
859        
860        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()));
861        
862        logger.info("Trying to delete contents. This can take a while...");
863        Map<String, Object> result = _contentDAO.deleteContents(contentsToRemove.stream().map(Content::getId).collect(Collectors.toList()), true);
864        logger.info("Contents deleting process ended.");
865        
866        List<Map<String, Object>> deletedContents = (List) result.get("deleted-contents");
867        _nbDeletedContents += deletedContents.size();
868        
869        List<Map<String, Object>> referencedContents = (List) result.get("referenced-contents");
870        if (referencedContents.size() > 0)
871        {
872            logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
873        }
874        
875        List<Map<String, Object>> lockedContents = (List) result.get("locked-contents");
876        if (lockedContents.size() > 0)
877        {
878            logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
879        }
880        
881        List<Map<String, Object>> undeletedContents = (List) result.get("undeleted-contents");
882        if (undeletedContents.size() > 0)
883        {
884            logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
885        }
886    }
887    
888    /**
889     * Get the value of metadata holding the unique identifier of the synchronized content
890     * @param content The content
891     * @return The value
892     */
893    protected String _getIdFieldValue(DefaultContent content)
894    {
895        ModifiableCompositeMetadata metadataHolder = _getMetadataHolder(content.getMetadataHolder(), getIdField());
896        
897        String[] pathSegments = getIdField().split("/");
898        String metadataName = pathSegments[pathSegments.length - 1];
899        
900        return metadataHolder.getString(metadataName, null);
901    }
902}