001/*
002 *  Copyright 2018 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.odfsync.cdmfr.components;
017
018import java.io.File;
019import java.io.FileInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.util.ArrayList;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Optional;
028import java.util.Set;
029
030import org.apache.avalon.framework.activity.Initializable;
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.configuration.Configurable;
033import org.apache.avalon.framework.configuration.Configuration;
034import org.apache.avalon.framework.configuration.ConfigurationException;
035import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
036import org.apache.avalon.framework.context.ContextException;
037import org.apache.avalon.framework.context.Contextualizable;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.cocoon.Constants;
042import org.apache.cocoon.ProcessingException;
043import org.apache.cocoon.environment.Context;
044import org.apache.commons.lang3.StringUtils;
045import org.apache.excalibur.xml.dom.DOMParser;
046import org.apache.excalibur.xml.xpath.XPathProcessor;
047import org.slf4j.Logger;
048import org.w3c.dom.Document;
049import org.w3c.dom.Element;
050import org.w3c.dom.Node;
051import org.w3c.dom.NodeList;
052import org.xml.sax.InputSource;
053import org.xml.sax.SAXException;
054
055import org.ametys.cms.ObservationConstants;
056import org.ametys.cms.contenttype.ContentType;
057import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
058import org.ametys.cms.data.ContentSynchronizationResult;
059import org.ametys.cms.repository.ContentQueryHelper;
060import org.ametys.cms.repository.ContentTypeExpression;
061import org.ametys.cms.repository.LanguageExpression;
062import org.ametys.cms.repository.ModifiableContent;
063import org.ametys.cms.repository.WorkflowAwareContent;
064import org.ametys.cms.workflow.ContentWorkflowHelper;
065import org.ametys.cms.workflow.EditContentFunction;
066import org.ametys.core.observation.Event;
067import org.ametys.core.observation.ObservationManager;
068import org.ametys.core.user.CurrentUserProvider;
069import org.ametys.core.user.population.UserPopulationDAO;
070import org.ametys.odf.ODFHelper;
071import org.ametys.odf.ProgramItem;
072import org.ametys.odf.catalog.CatalogsManager;
073import org.ametys.odf.course.Course;
074import org.ametys.odf.course.ShareableCourseHelper;
075import org.ametys.odf.courselist.CourseList;
076import org.ametys.odf.coursepart.CoursePart;
077import org.ametys.odf.enumeration.OdfReferenceTableHelper;
078import org.ametys.odf.observation.OdfObservationConstants;
079import org.ametys.odf.orgunit.OrgUnit;
080import org.ametys.odf.orgunit.RootOrgUnitProvider;
081import org.ametys.odf.program.Program;
082import org.ametys.odf.translation.TranslationHelper;
083import org.ametys.odf.workflow.AbstractCreateODFContentFunction;
084import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollection;
085import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollectionDataProvider;
086import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollectionHelper;
087import org.ametys.plugins.contentio.synchronize.workflow.EditSynchronizedContentFunction;
088import org.ametys.plugins.odfsync.cdmfr.CDMFrSyncExtensionPoint;
089import org.ametys.plugins.odfsync.cdmfr.extractor.ImportCDMFrValuesExtractor;
090import org.ametys.plugins.odfsync.cdmfr.extractor.ImportCDMFrValuesExtractorFactory;
091import org.ametys.plugins.odfsync.cdmfr.transformers.CDMFrSyncTransformer;
092import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription;
093import org.ametys.plugins.repository.AmetysObjectIterable;
094import org.ametys.plugins.repository.AmetysObjectResolver;
095import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus;
096import org.ametys.plugins.repository.data.extractor.ModelAwareValuesExtractor;
097import org.ametys.plugins.repository.data.holder.values.SynchronizationContext;
098import org.ametys.plugins.repository.jcr.NameHelper;
099import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
100import org.ametys.plugins.repository.query.expression.AndExpression;
101import org.ametys.plugins.repository.query.expression.Expression;
102import org.ametys.plugins.repository.query.expression.Expression.Operator;
103import org.ametys.plugins.repository.query.expression.OrExpression;
104import org.ametys.plugins.repository.query.expression.StringExpression;
105import org.ametys.plugins.workflow.AbstractWorkflowComponent;
106import org.ametys.plugins.workflow.AbstractWorkflowComponent.ConditionFailure;
107import org.ametys.plugins.workflow.component.CheckRightsCondition;
108import org.ametys.runtime.config.Config;
109import org.ametys.runtime.model.ModelItem;
110import org.ametys.runtime.model.View;
111
112import com.opensymphony.workflow.InvalidActionException;
113import com.opensymphony.workflow.WorkflowException;
114
115/**
116 * Abstract class of a component to import a CDM-fr input stream.
117 */
118public abstract class AbstractImportCDMFrComponent implements ImportCDMFrComponent, Serviceable, Initializable, Contextualizable, Configurable, Component
119{
120    /** Tag to identify a program */
121    protected static final String _TAG_PROGRAM = "program";
122
123    /** Tag to identify a subprogram */
124    protected static final String _TAG_SUBPROGRAM = "subProgram";
125    
126    /** Tag to identify a container */
127    protected static final String _TAG_CONTAINER = "container";
128    
129    /** Tag to identify a courseList */
130    protected static final String _TAG_COURSELIST = "coursesReferences";
131    
132    /** Tag to identify a coursePart */
133    protected static final String _TAG_COURSEPART = "coursePart";
134
135    /** The synchronize workflow action id */
136    protected static final int _SYNCHRONIZE_WORKFLOW_ACTION_ID = 800;
137    
138    /** The Cocoon context */
139    protected Context _cocoonContext;
140    
141    /** The DOM parser */
142    protected DOMParser _domParser;
143
144    /** The XPath processor */
145    protected XPathProcessor _xPathProcessor;
146
147    /** Extension point to transform CDM-fr */
148    protected CDMFrSyncExtensionPoint _cdmFrSyncExtensionPoint;
149
150    /** Default language configured for ODF */
151    protected String _odfLang;
152    
153    /** The catalog manager */
154    protected CatalogsManager _catalogsManager;
155
156    /** The ametys object resolver */
157    protected AmetysObjectResolver _resolver;
158
159    /** The ODF TableRef Helper */
160    protected OdfReferenceTableHelper _odfRefTableHelper;
161
162    /** The content type extension point */
163    protected ContentTypeExtensionPoint _contentTypeEP;
164
165    /** The current user provider */
166    protected CurrentUserProvider _currentUserProvider;
167
168    /** The observation manager */
169    protected ObservationManager _observationManager;
170    
171    /** The root orgunit provider */
172    protected RootOrgUnitProvider _rootOUProvider;
173
174    /** The ODF Helper */
175    protected ODFHelper _odfHelper;
176    
177    /** The SCC helper */
178    protected SynchronizableContentsCollectionHelper _sccHelper;
179    
180    /** The content workflow helper */
181    protected ContentWorkflowHelper _contentWorkflowHelper;
182    
183    /** The shareable course helper */
184    protected ShareableCourseHelper _shareableCourseHelper;
185    
186    /** the {@link ImportCDMFrValuesExtractor} factory */
187    protected ImportCDMFrValuesExtractorFactory _valuesExtractorFactory;
188    
189    
190    /** List of imported contents */
191    protected Map<String, Integer> _importedContents;
192    
193    /** List of synchronized contents */
194    protected Set<String> _synchronizedContents;
195
196    /** Number of errors encountered */
197    protected int _nbError;
198    /** The prefix of the contents */
199    protected String _contentPrefix;
200    /** Synchronized fields by content type */
201    protected Map<String, Set<String>> _syncFieldsByContentType;
202
203    public void initialize() throws Exception
204    {
205        _odfLang = Config.getInstance().getValue("odf.programs.lang");
206    }
207
208    @Override
209    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
210    {
211        _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
212    }
213
214    public void configure(Configuration configuration) throws ConfigurationException
215    {
216        _parseSynchronizedFields();
217    }
218    
219    @Override
220    public void service(ServiceManager manager) throws ServiceException
221    {
222        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
223        _domParser = (DOMParser) manager.lookup(DOMParser.ROLE);
224        _xPathProcessor = (XPathProcessor) manager.lookup(XPathProcessor.ROLE);
225        _cdmFrSyncExtensionPoint = (CDMFrSyncExtensionPoint) manager.lookup(CDMFrSyncExtensionPoint.ROLE);
226        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
227        _odfRefTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
228        _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
229        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
230        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
231        _rootOUProvider = (RootOrgUnitProvider) manager.lookup(RootOrgUnitProvider.ROLE);
232        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
233        _sccHelper = (SynchronizableContentsCollectionHelper) manager.lookup(SynchronizableContentsCollectionHelper.ROLE);
234        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
235        _shareableCourseHelper = (ShareableCourseHelper) manager.lookup(ShareableCourseHelper.ROLE);
236        _valuesExtractorFactory = (ImportCDMFrValuesExtractorFactory) manager.lookup(ImportCDMFrValuesExtractorFactory.ROLE);
237    }
238
239    @Override
240    public String getIdField()
241    {
242        return "cdmfrSyncCode";
243    }
244
245    /**
246     * Get the synchronized metadata from the configuration file
247     * @throws ConfigurationException if the configuration is not valid.
248     */
249    private void _parseSynchronizedFields() throws ConfigurationException
250    {
251        _syncFieldsByContentType = new HashMap<>();
252        
253        File cdmfrMapping = new File(_cocoonContext.getRealPath("/WEB-INF/param/odf-synchro.xml"));
254        try (InputStream is = !cdmfrMapping.isFile()
255                            ? getClass().getResourceAsStream("/org/ametys/plugins/odfsync/cdmfr/odf-synchro.xml")
256                            : new FileInputStream(cdmfrMapping))
257        {
258            Configuration cfg = new DefaultConfigurationBuilder().build(is);
259
260            Configuration[] cTypesConf = cfg.getChildren("content-type");
261            for (Configuration cTypeConf : cTypesConf)
262            {
263                String contentType = cTypeConf.getAttribute("id");
264                Set<String> syncAttributes = _configureSynchronizedFields(cTypeConf, StringUtils.EMPTY);
265                _syncFieldsByContentType.put(contentType, syncAttributes);
266            }
267        }
268        catch (Exception e)
269        {
270            throw new ConfigurationException("Error while parsing odf-synchro.xml", e);
271        }
272    }
273
274    private Set<String> _configureSynchronizedFields(Configuration configuration, String prefix) throws ConfigurationException
275    {
276        Set<String> syncAttributes = new HashSet<>();
277        Configuration[] attributesConf = configuration.getChildren("attribute");
278        
279        if (attributesConf.length > 0)
280        {
281            for (Configuration attributeConf : attributesConf)
282            {
283                if (attributeConf.getChildren("attribute").length > 0)
284                {
285                    // composite
286                    syncAttributes.addAll(_configureSynchronizedFields(attributeConf, prefix + attributeConf.getAttribute("name") + ModelItem.ITEM_PATH_SEPARATOR));
287                }
288                else
289                {
290                    syncAttributes.add(prefix + attributeConf.getAttribute("name"));
291                }
292            }
293        }
294        else if (configuration.getAttribute("name", null) != null)
295        {
296            syncAttributes.add(prefix + configuration.getAttribute("name"));
297        }
298        
299        return syncAttributes;
300    }
301    
302    @Override
303    @SuppressWarnings("unchecked")
304    public synchronized Map<String, Object> handleInputStream(InputStream input, Map<String, Object> parameters, SynchronizableContentsCollection scc, Logger logger) throws ProcessingException
305    {
306        List<ModifiableContent> importedPrograms = new ArrayList<>();
307        
308        _importedContents = new HashMap<>();
309        _synchronizedContents = (Set<String>) parameters.getOrDefault("updatedContents", new HashSet<>());
310        int nbCreatedContents = (int) parameters.getOrDefault("nbCreatedContents", 0);
311        int nbSynchronizedContents = (int) parameters.getOrDefault("nbSynchronizedContents", 0);
312        _nbError = (int) parameters.getOrDefault("nbError", 0);
313        _contentPrefix = (String) parameters.getOrDefault("contentPrefix", "cdmfr-");
314        additionalParameters(parameters);
315
316        Map<String, Object> resultMap = new HashMap<>();
317        
318        try
319        {
320            Document doc = _domParser.parseDocument(new InputSource(input));
321            doc = transformDocument(doc, new HashMap<>(), logger);
322            
323            if (doc != null)
324            {
325                String defaultLang = _getXPathString(doc, "CDM/@language", _odfLang);
326                
327                NodeList nodes = doc.getElementsByTagName(_TAG_PROGRAM);
328                
329                for (int i = 0; i < nodes.getLength(); i++)
330                {
331                    Element contentElement = (Element) nodes.item(i);
332                    String syncCode = _xPathProcessor.evaluateAsString(contentElement, "@CDMid").trim();
333                    String contentLang = _getXPathString(contentElement, "@language", defaultLang);
334                    contentLang = StringUtils.substring(contentLang, 0, 2).toLowerCase(); // on keep the language from the locale
335                    
336                    String catalog = getCatalogName(contentElement);
337
338                    ImportCDMFrContext context = new ImportCDMFrContext(scc, doc, contentLang, catalog, logger);
339                    importedPrograms.add(importOrSynchronizeContent(contentElement, ContentWorkflowDescription.PROGRAM_WF_DESCRIPTION, syncCode, syncCode, context));
340                }
341                
342                // Validate newly imported contents
343                if (validateAfterImport())
344                {
345                    for (String contentId : _importedContents.keySet())
346                    {
347                        WorkflowAwareContent content = _resolver.resolveById(contentId);
348                        Integer validationActionId = _importedContents.get(contentId);
349                        if (validationActionId > 0)
350                        {
351                            validateContent(content, validationActionId, logger);
352                        }
353                    }
354                }
355            }
356        }
357        catch (IOException | ProcessingException e)
358        {
359            throw new ProcessingException("An error occured while transforming the stream.", e);
360        }
361        catch (SAXException e)
362        {
363            throw new ProcessingException("An error occured while parsing the stream.", e);
364        }
365        catch (Exception e)
366        {
367            throw new ProcessingException("An error occured while synchronizing values on contents.", e);
368        }
369
370        resultMap.put("importedContents", _importedContents.keySet());
371        resultMap.put("nbCreatedContents", nbCreatedContents + _importedContents.size());
372        resultMap.put("updatedContents", _synchronizedContents);
373        resultMap.put("nbSynchronizedContents", nbSynchronizedContents + _synchronizedContents.size());
374        resultMap.put("nbError", _nbError);
375        resultMap.put("importedPrograms", importedPrograms);
376
377        return resultMap;
378    }
379
380    /**
381     * True to validate the contents after import
382     * @return True to validate the contents after import
383     */
384    protected abstract boolean validateAfterImport();
385
386    /**
387     * When returns true, a content created by a previous synchro will be removed if it does not exist anymore during the current synchro.
388     * @return true if a content created by a previous synchro has to be removed if it does not exist anymore during the current synchro.
389     */
390    protected abstract boolean removalSync();
391    
392    /**
393     * Additional parameters for specific treatments.
394     * @param parameters The parameters map to get
395     */
396    protected abstract void additionalParameters(Map<String, Object> parameters);
397    
398    /**
399     * Transform the document depending of it structure.
400     * @param document Document to transform.
401     * @param parameters Optional parameters for transformation
402     * @param logger The logger
403     * @return The transformed document.
404     * @throws IOException if an error occurs.
405     * @throws SAXException if an error occurs.
406     * @throws ProcessingException if an error occurs.
407     */
408    protected Document transformDocument(Document document, Map<String, Object> parameters, Logger logger) throws IOException, SAXException, ProcessingException
409    {
410        Optional<CDMFrSyncTransformer> transformer = _cdmFrSyncExtensionPoint.getFirstSupportingExtension(document);
411        
412        if (transformer.isEmpty())
413        {
414            logger.error("Cannot match a CDM-fr transformer to this file structure.");
415            return null;
416        }
417        
418        return transformer.get().transform(document, parameters);
419    }
420
421    public String getCatalogName(Element contentElement)
422    {
423        String defaultCatalog = _catalogsManager.getDefaultCatalogName();
424        
425        String contentCatalog = _getXPathString(contentElement, "catalog", defaultCatalog);
426        if (_catalogsManager.getCatalog(contentCatalog) == null)
427        {
428            // Catalog is empty or do not exist, use the default catalog
429            return defaultCatalog;
430        }
431        
432        return contentCatalog;
433    }
434    
435    public ModifiableContent importOrSynchronizeContent(Element contentElement, ContentWorkflowDescription wfDescription, String title, String syncCode, ImportCDMFrContext context)
436    {
437        ModifiableContent content = _getOrCreateContent(wfDescription, title, syncCode, context);
438        
439        if (content != null)
440        {
441            try
442            {
443                _sccHelper.updateLastSynchronizationProperties(content);
444                content.saveChanges();
445                _synchronizeContent(contentElement, content, wfDescription.getContentType(), syncCode, context);
446            }
447            catch (Exception e)
448            {
449                _nbError++;
450                context.logger().error("Failed to synchronize data for content {} and language {}.", content, context.lang(), e);
451            }
452        }
453        
454        return content;
455    }
456    
457    /**
458     * Get or create the content from the workflow description, the synchronization code and the import context.
459     * @param wfDescription The workflow description
460     * @param title The title
461     * @param syncCode The synchronization code
462     * @param context The import context
463     * @return the retrieved or created content
464     */
465    protected ModifiableContent _getOrCreateContent(ContentWorkflowDescription wfDescription, String title, String syncCode, ImportCDMFrContext context)
466    {
467        ModifiableContent receivedContent = getContent(wfDescription.getContentType(), syncCode, context);
468        if (receivedContent != null)
469        {
470            return receivedContent;
471        }
472        
473        try
474        {
475            context.logger().info("Creating content '{}' with the content type '{}' for language {}", title, wfDescription.getContentType(), context.lang());
476            
477            ModifiableContent content = _createContent(wfDescription, title, context);
478            if (content != null)
479            {
480                _sccHelper.updateSCCProperty(content, context.scc().getId());
481                content.setValue(getIdField(), syncCode);
482                content.saveChanges();
483                _importedContents.put(content.getId(), wfDescription.getValidationActionId());
484            }
485            
486            return content;
487        }
488        catch (WorkflowException e)
489        {
490            context.logger().error("Failed to initialize workflow for content {} and language {}", title, context.lang(), e);
491            _nbError++;
492            return null;
493        }
494    }
495
496    public ModifiableContent getContent(String contentType, String syncCode, ImportCDMFrContext context)
497    {
498        List<Expression> expList = _getExpressionsList(contentType, syncCode, context);
499        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
500        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
501
502        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
503
504        if (contents.getSize() > 0)
505        {
506            return contents.iterator().next();
507        }
508        
509        return null;
510    }
511    
512    /**
513     * Create the content from the workflow description and the import context.
514     * @param wfDescription The workflow description
515     * @param title The title
516     * @param context The import context
517     * @return the created content
518     * @throws WorkflowException if an error occurs while creating the content
519     */
520    protected ModifiableContent _createContent(ContentWorkflowDescription wfDescription, String title, ImportCDMFrContext context) throws WorkflowException
521    {
522        String contentName = NameHelper.filterName(_contentPrefix + "-" + title + "-" + context.lang());
523        
524        Map<String, Object> inputs = _getInputsForContentCreation(wfDescription, context);
525        Map<String, Object> result = _contentWorkflowHelper.createContent(
526                wfDescription.getWorkflowName(),
527                wfDescription.getInitialActionId(),
528                contentName,
529                title,
530                new String[] {wfDescription.getContentType()},
531                null,
532                context.lang(),
533                inputs);
534        
535        return _resolver.resolveById((String) result.get("contentId"));
536    }
537    
538    /**
539     * Retrieves the inputs to give for content creation
540     * @param wfDescription The workflow description
541     * @param context The import context
542     * @return the inputs to give for content creation
543     */
544    protected Map<String, Object> _getInputsForContentCreation(ContentWorkflowDescription wfDescription, ImportCDMFrContext context)
545    {
546        Map<String, Object> inputs = new HashMap<>();
547        
548        ContentType contentType = _contentTypeEP.getExtension(wfDescription.getContentType());
549        if (contentType.hasModelItem(ProgramItem.CATALOG) || contentType.hasModelItem(CoursePart.CATALOG))
550        {
551            inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, context.catalog());
552        }
553        
554        return inputs;
555    }
556    
557    /**
558     * Synchronize content
559     * @param contentElement the DOM content element
560     * @param content The content to synchronize
561     * @param contentTypeId The content type ID
562     * @param syncCode The synchronization code
563     * @param context the import context
564     * @throws Exception if an error occurs while synchronizing the content values
565     */
566    protected void _synchronizeContent(Element contentElement, ModifiableContent content, String contentTypeId, String syncCode, ImportCDMFrContext context) throws Exception
567    {
568        Logger logger = context.logger();
569        
570        // Avoid a treatment twice or more
571        if (_synchronizedContents.add(content.getId()))
572        {
573            logger.info("Synchronization of the content '{}' with the content type '{}'", content.getTitle(), contentTypeId);
574            
575            if (content instanceof LockAwareAmetysObject && ((LockAwareAmetysObject) content).isLocked())
576            {
577                logger.warn("The content '{}' ({}) is currently locked by user {}: it cannot be synchronized", content.getTitle(), content.getId(), ((LockAwareAmetysObject) content).getLockOwner());
578            }
579            else if (content instanceof WorkflowAwareContent)
580            {
581                ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
582                ModelAwareValuesExtractor valuesExtractor = _valuesExtractorFactory.getValuesExtractor(contentElement, this, content, contentType, syncCode, context);
583                
584                // Extract the values
585                Map<String, Object> values = valuesExtractor.extractValues();
586                values.putAll(_getAdditionalValuesToSynchronize(content, syncCode, context));
587                
588                // Modify the content with the extracted values
589                boolean create = _importedContents.containsKey(content.getId());
590                Set<String> notSynchronizedContentIds = _getNotSynchronizedRelatedContentIds(content, syncCode, context);
591                _editContent((WorkflowAwareContent) content, Optional.empty(), values, create, notSynchronizedContentIds, context);
592                
593                if (content instanceof OrgUnit)
594                {
595                    _setOrgUnitParent((WorkflowAwareContent) content, context);
596                }
597                
598                // Create translation links
599                _linkTranslationsIfExist(content, contentTypeId, context);
600            }
601        }
602    }
603
604    /**
605     * Retrieves additional values to synchronize for the content
606     * @param content the content
607     * @param syncCode the content synchronization code
608     * @param context the import context
609     * @return the additional values
610     */
611    protected Map<String, Object> _getAdditionalValuesToSynchronize(ModifiableContent content, String syncCode, ImportCDMFrContext context)
612    {
613        Map<String, Object> additionalValues = new HashMap<>();
614        additionalValues.put(getIdField(), syncCode);
615        return additionalValues;
616    }
617    
618    /**
619     * Retrieves the ids of the contents related to the given content but that are not part of the synchronization
620     * @param content the content
621     * @param syncCode the content synchronization code
622     * @param context the import context
623     * @return the not synchronized content ids
624     */
625    protected Set<String> _getNotSynchronizedRelatedContentIds(ModifiableContent content, String syncCode, ImportCDMFrContext context)
626    {
627        return new HashSet<>();
628    }
629    
630    /**
631     * Synchronize the content with given values.
632     * @param content The content to synchronize
633     * @param view the view containing the item to edit
634     * @param values the values
635     * @param create <code>true</code> if content is creating, false if it is updated
636     * @param notSynchronizedContentIds the ids of the contents related to the given content but that are not part of the synchronization
637     * @param context the import context
638     * @throws WorkflowException if an error occurs
639     */
640    protected void _editContent(WorkflowAwareContent content, Optional<View> view, Map<String, Object> values, boolean create, Set<String> notSynchronizedContentIds, ImportCDMFrContext context) throws WorkflowException
641    {
642        SynchronizationContext synchronizationContext = SynchronizationContext.newInstance()
643                                                                              .withStatus(ExternalizableDataStatus.EXTERNAL)
644                                                                              .withExternalizableDataContextEntry(SynchronizableContentsCollectionDataProvider.SCC_ID_CONTEXT_KEY, context.scc().getId())
645                                                                              .withIncompatibleValuesIgnored(true);
646
647        if (view.map(v -> content.hasDifferences(v, values, synchronizationContext))
648                .orElseGet(() -> content.hasDifferences(values, synchronizationContext)))
649        {
650            context.logger().info("Some changes were detected for content '{}' and language {}", content.getTitle(), context.lang());
651            
652            Map<String, Object> inputs = new HashMap<>();
653            inputs.put(EditSynchronizedContentFunction.SCC_KEY, context.scc());
654            inputs.put(EditSynchronizedContentFunction.SCC_LOGGER_KEY, context.logger());
655            inputs.put(EditSynchronizedContentFunction.NOT_SYNCHRONIZED_RELATED_CONTENT_IDS_KEY, notSynchronizedContentIds);
656            if (ignoreRights())
657            {
658                inputs.put(CheckRightsCondition.FORCE, true);
659            }
660    
661            Map<String, Object> params = new HashMap<>();
662            // Remove catalog data, this value is forced at creation and should not be modified
663            values.remove(ProgramItem.CATALOG);
664            params.put(EditContentFunction.VALUES_KEY, values);
665            view.ifPresent(v -> params.put(EditContentFunction.VIEW, v));
666            params.put(EditContentFunction.QUIT, true);
667            params.put(EditSynchronizedContentFunction.IMPORT, create);
668            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, params);
669            
670            _contentWorkflowHelper.doAction(content, _SYNCHRONIZE_WORKFLOW_ACTION_ID, inputs);
671        }
672        else
673        {
674            context.logger().info("No changes detected for content '{}' and language {}", content.getTitle(), context.lang());
675        }
676    }
677    
678    public ContentSynchronizationResult additionalOperations(ModifiableContent content, Map<String, Object> additionalParameters, Logger logger)
679    {
680        ContentSynchronizationResult result = new ContentSynchronizationResult();
681
682        if (content instanceof Program)
683        {
684            List<ModifiableContent> modifiedContents = _initializeShareableCoursesFields((Program) content);
685            
686            result.addModifiedContents(modifiedContents);
687            result.setHasChanged(!modifiedContents.isEmpty());
688        }
689        
690        return result;
691    }
692    
693    /**
694     * Initialize shareable fields for the courses under the given {@link ProgramItem}
695     * @param programItem the program item
696     * @return the list of contents that have been modified during the initialization
697     */
698    protected List<ModifiableContent> _initializeShareableCoursesFields(ProgramItem programItem)
699    {
700        List<ModifiableContent> modifiedContents = new ArrayList<>();
701
702        List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem);
703        for (ProgramItem child : children)
704        {
705            if (child instanceof Course && programItem instanceof CourseList)
706            {
707                if (_shareableCourseHelper.initializeShareableFields((Course) child, (CourseList) programItem,  UserPopulationDAO.SYSTEM_USER_IDENTITY, true))
708                {
709                    modifiedContents.add((Course) child);
710                }
711            }
712            
713            modifiedContents.addAll(_initializeShareableCoursesFields(child));
714        }
715
716        return modifiedContents;
717    }
718    
719    /**
720     * Search for translated contents
721     * @param importedContent The imported content
722     * @param contentType The content type
723     * @param context the import context
724     */
725    protected void _linkTranslationsIfExist(ModifiableContent importedContent, String contentType, ImportCDMFrContext context)
726    {
727        if (importedContent instanceof ProgramItem)
728        {
729            Expression expression = _getTranslationExpression(importedContent, contentType);
730            String xPathQuery = ContentQueryHelper.getContentXPathQuery(expression);
731    
732            AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
733            
734            Map<String, String> translations = new HashMap<>();
735            for (ModifiableContent content : contents)
736            {
737                translations.put(content.getLanguage(), content.getId());
738            }
739            
740            for (ModifiableContent content : contents)
741            {
742                TranslationHelper.setTranslations(content, translations);
743    
744                Map<String, Object> eventParams = new HashMap<>();
745                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
746                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
747                _observationManager.notify(new Event(OdfObservationConstants.ODF_CONTENT_TRANSLATED, _currentUserProvider.getUser(), eventParams));
748            }
749        }
750    }
751    
752    private Expression _getTranslationExpression(ModifiableContent content, String contentType)
753    {
754        List<Expression> expList = new ArrayList<>();
755        
756        if (StringUtils.isNotBlank(contentType))
757        {
758            expList.add(new ContentTypeExpression(Operator.EQ, contentType));
759        }
760        
761        String catalog = content.getValue(ProgramItem.CATALOG);
762        if (StringUtils.isNotBlank(catalog))
763        {
764            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
765        }
766        
767        List<Expression> codeExpressionList = new ArrayList<>();
768        String syncValue = content.getValue(getIdField());
769        if (StringUtils.isNotBlank(syncValue))
770        {
771            codeExpressionList.add(new StringExpression(getIdField(), Operator.EQ, syncValue));
772        }
773
774        String code = content.getValue(ProgramItem.CODE);
775        if (StringUtils.isNotBlank(syncValue))
776        {
777            codeExpressionList.add(new StringExpression(ProgramItem.CODE, Operator.EQ, code));
778        }
779        
780        if (!codeExpressionList.isEmpty())
781        {
782            expList.add(new OrExpression(codeExpressionList.toArray(Expression[]::new)));
783        }
784        
785        return new AndExpression(expList.toArray(Expression[]::new));
786    }
787
788    /**
789     * Set the orgUnit parent to rootOrgUnit.
790     * @param orgUnit The orgunit to link
791     * @param context the import context
792     * @throws Exception if an error occurs while synchronizing the content values
793     */
794    protected void _setOrgUnitParent(WorkflowAwareContent orgUnit, ImportCDMFrContext context) throws Exception
795    {
796        // Set the orgUnit parent (if no parent is set)
797        if (!orgUnit.hasValue(OrgUnit.PARENT_ORGUNIT))
798        {
799            OrgUnit rootOrgUnit = _rootOUProvider.getRoot();
800            Map<String, Object> values = new HashMap<>();
801            values.put(OrgUnit.PARENT_ORGUNIT, rootOrgUnit);
802            _editContent(orgUnit, Optional.empty(), values, false, Set.of(rootOrgUnit.getId()), context);
803        }
804    }
805    
806    /**
807     * Validates a content after import
808     * @param content The content to validate
809     * @param validationActionId Validation action ID to use for this content
810     * @param logger The logger
811     */
812    protected void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger)
813    {
814        Map<String, Object> inputs = new HashMap<>();
815        if (ignoreRights())
816        {
817            inputs.put(CheckRightsCondition.FORCE, true);
818        }
819        
820        try
821        {
822            _contentWorkflowHelper.doAction(content, validationActionId, inputs);
823            logger.info("The content {} has been validated after import", content);
824        }
825        catch (WorkflowException | InvalidActionException e)
826        {
827            String failuresAsString = _getActionFailuresAsString(inputs);
828            logger.error("The content {} cannot be validated after import{}", content, failuresAsString, e);
829        }
830    }
831    
832    private String _getActionFailuresAsString(Map<String, Object> actionInputs)
833    {
834        String failuresAsString = "";
835        if (actionInputs.containsKey(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY))
836        {
837            @SuppressWarnings("unchecked")
838            List<ConditionFailure> failures = (List<ConditionFailure>) actionInputs.get(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY);
839            if (!failures.isEmpty())
840            {
841                failuresAsString = ", due to the following error(s):\n" + String.join("\n", failures.stream().map(ConditionFailure::text).toList());
842            }
843        }
844        
845        return failuresAsString;
846    }
847    
848    private String _getXPathString(Node metadataNode, String xPath, String defaultValue)
849    {
850        String value = _xPathProcessor.evaluateAsString(metadataNode, xPath);
851        if (StringUtils.isEmpty(value))
852        {
853            value = defaultValue;
854        }
855        return value;
856    }
857
858    /**
859     * If true, bypass the rights check during the import process
860     * @return True if the rights check are bypassed during the import process
861     */
862    protected boolean ignoreRights()
863    {
864        return false;
865    }
866    
867    /**
868     * Construct the query to retrieve the content.
869     * @param contentTypeId The content type
870     * @param syncCode The synchronization code
871     * @param context the import context
872     * @return The {@link List} of {@link Expression}
873     */
874    protected List<Expression> _getExpressionsList(String contentTypeId, String syncCode, ImportCDMFrContext context)
875    {
876        List<Expression> expList = new ArrayList<>();
877        
878        if (StringUtils.isNotBlank(contentTypeId))
879        {
880            expList.add(new ContentTypeExpression(Operator.EQ, contentTypeId));
881            
882            if (StringUtils.isNotBlank(context.catalog()))
883            {
884                ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
885                if (contentType.hasModelItem(ProgramItem.CATALOG) || contentType.hasModelItem(CoursePart.CATALOG))
886                {
887                    expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, context.catalog()));
888                }
889            }
890        }
891        
892        if (StringUtils.isNotBlank(syncCode))
893        {
894            expList.add(new StringExpression(getIdField(), Operator.EQ, syncCode));
895        }
896        
897        if (StringUtils.isNotBlank(context.lang()))
898        {
899            expList.add(new LanguageExpression(Operator.EQ, context.lang()));
900        }
901        
902        return expList;
903    }
904
905    @Override
906    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
907    {
908        if (additionalParameters == null || !additionalParameters.containsKey("contentTypes"))
909        {
910            throw new IllegalArgumentException("Content types shouldn't be null.");
911        }
912
913        @SuppressWarnings("unchecked")
914        List<String> contentTypeIds = (List<String>) additionalParameters.get("contentTypes");
915        Set<String> allSyncFields = new HashSet<>();
916        
917        for (String contentTypeId : contentTypeIds)
918        {
919            Set<String> syncFields = _syncFieldsByContentType.computeIfAbsent(contentTypeId, k -> new HashSet<>());
920            allSyncFields.addAll(syncFields);
921        }
922        
923        return allSyncFields;
924    }
925}