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.ByteArrayInputStream;
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.net.URL;
024import java.text.ParseException;
025import java.text.SimpleDateFormat;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.Date;
029import java.util.HashMap;
030import java.util.HashSet;
031import java.util.LinkedList;
032import java.util.List;
033import java.util.Map;
034import java.util.Objects;
035import java.util.Set;
036
037import javax.jcr.RepositoryException;
038import javax.xml.transform.TransformerException;
039
040import org.apache.avalon.framework.activity.Initializable;
041import org.apache.avalon.framework.component.Component;
042import org.apache.avalon.framework.configuration.Configurable;
043import org.apache.avalon.framework.configuration.Configuration;
044import org.apache.avalon.framework.configuration.ConfigurationException;
045import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
046import org.apache.avalon.framework.context.ContextException;
047import org.apache.avalon.framework.context.Contextualizable;
048import org.apache.avalon.framework.service.ServiceException;
049import org.apache.avalon.framework.service.ServiceManager;
050import org.apache.avalon.framework.service.Serviceable;
051import org.apache.cocoon.Constants;
052import org.apache.cocoon.ProcessingException;
053import org.apache.cocoon.environment.Context;
054import org.apache.commons.collections4.CollectionUtils;
055import org.apache.commons.io.IOUtils;
056import org.apache.commons.lang3.StringUtils;
057import org.apache.excalibur.xml.dom.DOMParser;
058import org.apache.excalibur.xml.xpath.PrefixResolver;
059import org.apache.excalibur.xml.xpath.XPathProcessor;
060import org.slf4j.Logger;
061import org.w3c.dom.Document;
062import org.w3c.dom.Element;
063import org.w3c.dom.NamedNodeMap;
064import org.w3c.dom.Node;
065import org.w3c.dom.NodeList;
066import org.xml.sax.InputSource;
067import org.xml.sax.SAXException;
068
069import org.ametys.cms.ObservationConstants;
070import org.ametys.cms.content.external.ExternalizableMetadataHelper;
071import org.ametys.cms.content.external.ExternalizableMetadataProvider.ExternalizableMetadataStatus;
072import org.ametys.cms.contenttype.ContentType;
073import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
074import org.ametys.cms.contenttype.MetadataDefinition;
075import org.ametys.cms.contenttype.MetadataType;
076import org.ametys.cms.contenttype.RepeaterDefinition;
077import org.ametys.cms.repository.ContentQueryHelper;
078import org.ametys.cms.repository.ContentTypeExpression;
079import org.ametys.cms.repository.LanguageExpression;
080import org.ametys.cms.repository.ModifiableContent;
081import org.ametys.cms.repository.ModifiableDefaultContent;
082import org.ametys.cms.repository.comment.CommentsDAO;
083import org.ametys.core.observation.Event;
084import org.ametys.core.observation.ObservationManager;
085import org.ametys.core.user.CurrentUserProvider;
086import org.ametys.core.user.population.UserPopulationDAO;
087import org.ametys.core.util.URIUtils;
088import org.ametys.odf.ProgramItem;
089import org.ametys.odf.catalog.CatalogsManager;
090import org.ametys.odf.course.Course;
091import org.ametys.odf.course.CourseFactory;
092import org.ametys.odf.course.ShareableCourseHelper;
093import org.ametys.odf.courselist.CourseList;
094import org.ametys.odf.courselist.CourseListFactory;
095import org.ametys.odf.coursepart.CoursePart;
096import org.ametys.odf.coursepart.CoursePartFactory;
097import org.ametys.odf.enumeration.OdfReferenceTableEntry;
098import org.ametys.odf.enumeration.OdfReferenceTableHelper;
099import org.ametys.odf.observation.OdfObservationConstants;
100import org.ametys.odf.orgunit.OrgUnit;
101import org.ametys.odf.orgunit.OrgUnitFactory;
102import org.ametys.odf.orgunit.RootOrgUnitProvider;
103import org.ametys.odf.person.PersonFactory;
104import org.ametys.odf.program.ContainerFactory;
105import org.ametys.odf.program.ProgramFactory;
106import org.ametys.odf.program.ProgramPart;
107import org.ametys.odf.program.SubProgramFactory;
108import org.ametys.odf.program.TraversableProgramPart;
109import org.ametys.odf.translation.TranslationHelper;
110import org.ametys.plugins.contentio.ContentImporterHelper;
111import org.ametys.plugins.contentio.synchronize.BaseSynchroComponent;
112import org.ametys.plugins.odfsync.cdmfr.CDMFrSyncExtensionPoint;
113import org.ametys.plugins.odfsync.cdmfr.transformers.CDMFrSyncTransformer;
114import org.ametys.plugins.repository.AmetysObjectIterable;
115import org.ametys.plugins.repository.AmetysObjectResolver;
116import org.ametys.plugins.repository.AmetysRepositoryException;
117import org.ametys.plugins.repository.metadata.CompositeMetadata;
118import org.ametys.plugins.repository.metadata.ModifiableBinaryMetadata;
119import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
120import org.ametys.plugins.repository.metadata.ModifiableFile;
121import org.ametys.plugins.repository.metadata.ModifiableResource;
122import org.ametys.plugins.repository.metadata.ModifiableRichText;
123import org.ametys.plugins.repository.query.expression.AndExpression;
124import org.ametys.plugins.repository.query.expression.Expression;
125import org.ametys.plugins.repository.query.expression.Expression.Operator;
126import org.ametys.plugins.repository.query.expression.OrExpression;
127import org.ametys.plugins.repository.query.expression.StringExpression;
128import org.ametys.runtime.config.Config;
129
130/**
131 * Abstract class of a component to import a CDM-fr input stream.
132 */
133public abstract class AbstractImportCDMFrComponent implements ImportCDMFrComponent, Serviceable, Initializable, Contextualizable, Configurable, Component
134{
135    /** Tag to identify a program */
136    protected static final String _TAG_PROGRAM = "program";
137
138    /** Tag to identify a subprogram */
139    protected static final String _TAG_SUBPROGRAM = "subProgram";
140    
141    /** Tag to identify a container */
142    protected static final String _TAG_CONTAINER = "container";
143    
144    /** Tag to identify a courseList */
145    protected static final String _TAG_COURSELIST = "coursesReferences";
146    
147    /** Tag to identify a coursePart */
148    protected static final String _TAG_COURSEPART = "coursePart";
149
150    /** The synchronize workflow action id */
151    private static final int SYNCHRONIZE_WORKFLOW_ACTION_ID = 800;
152    
153    private static final ContentWorkflowDescription _PROGRAM_WF_DESCRIPTION = new ContentWorkflowDescription(ProgramFactory.PROGRAM_CONTENT_TYPE, "program", 1, 4);
154    private static final ContentWorkflowDescription _SUBPROGRAM_WF_DESCRIPTION = new ContentWorkflowDescription(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, "subprogram", 1, 4);
155    private static final ContentWorkflowDescription _CONTAINER_WF_DESCRIPTION = new ContentWorkflowDescription(ContainerFactory.CONTAINER_CONTENT_TYPE, "container", 1, 4);
156    private static final ContentWorkflowDescription _COURSELIST_WF_DESCRIPTION = new ContentWorkflowDescription(CourseListFactory.COURSE_LIST_CONTENT_TYPE, "courselist", 1, 4);
157    private static final ContentWorkflowDescription _COURSE_WF_DESCRIPTION = new ContentWorkflowDescription(CourseFactory.COURSE_CONTENT_TYPE, "course", 1, 4);
158    private static final ContentWorkflowDescription _COURSEPART_WF_DESCRIPTION = new ContentWorkflowDescription(CoursePartFactory.COURSE_PART_CONTENT_TYPE, "course-part", 1, -1);
159    private static final ContentWorkflowDescription _ORGUNIT_WF_DESCRIPTION = new ContentWorkflowDescription(OrgUnitFactory.ORGUNIT_CONTENT_TYPE, "orgunit", 1, 4);
160    private static final ContentWorkflowDescription _PERSON_WF_DESCRIPTION = new ContentWorkflowDescription(PersonFactory.PERSON_CONTENT_TYPE, "person", 1, 4);
161
162    private static final PrefixResolver _PREFIX_RESOLVER = new DocbookPrefixResolver();
163    
164    /** The Cocoon context */
165    protected Context _cocoonContext;
166    
167    /** The DOM parser */
168    protected DOMParser _domParser;
169
170    /** The XPath processor */
171    protected XPathProcessor _xPathProcessor;
172
173    /** Extension point to transform CDM-fr */
174    protected CDMFrSyncExtensionPoint _cdmFrSyncExtensionPoint;
175
176    /** Default language configured for ODF */
177    protected String _odfLang;
178    
179    /** The catalog manager */
180    protected CatalogsManager _catalogsManager;
181
182    /** The ametys object resolver */
183    protected AmetysObjectResolver _resolver;
184
185    /** The ODF TableRef Helper */
186    protected OdfReferenceTableHelper _odfRefTableHelper;
187
188    /** The content type extension point */
189    protected ContentTypeExtensionPoint _contentTypeEP;
190
191    /** The current user provider */
192    protected CurrentUserProvider _currentUserProvider;
193
194    /** The observation manager */
195    protected ObservationManager _observationManager;
196    
197    /** The root orgunit provider */
198    protected RootOrgUnitProvider _rootOUProvider;
199
200    /** The base SCC component */
201    protected BaseSynchroComponent _synchroComponent;
202    
203    /** The shareable course helper */
204    protected ShareableCourseHelper _shareableCourseHelper;
205    
206    /** List of synchronized contents */
207    protected Map<String, Integer> _importedContents;
208    
209    /** List of synchronized contents having differences */
210    protected Set<String> _synchronizedContents;
211    
212    /** List of synchronized contents (to avoid a treatment twice or more) */
213    protected Set<String> _updatedContents;
214
215    /** Number of errors encountered */
216    protected int _nbError;
217    /** Number of created contents */
218    protected int _nbCreatedContents;
219    /** Number of synchronized contents */
220    protected int _nbSynchronizedContents;
221    /** Number of unchanged contents */
222    protected int _nbNotChangedContents;
223    /** The prefix of the contents */
224    protected String _contentPrefix;
225    /** Synchronized fields by content type */
226    protected Map<String, Set<String>> _syncFieldsByContentType;
227
228    public void initialize() throws Exception
229    {
230        _odfLang = Config.getInstance().getValue("odf.programs.lang");
231    }
232
233    @Override
234    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
235    {
236        _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
237    }
238
239    public void configure(Configuration configuration) throws ConfigurationException
240    {
241        _parseSynchronizedFields();
242    }
243    
244    @Override
245    public void service(ServiceManager manager) throws ServiceException
246    {
247        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
248        _domParser = (DOMParser) manager.lookup(DOMParser.ROLE);
249        _xPathProcessor = (XPathProcessor) manager.lookup(XPathProcessor.ROLE);
250        _cdmFrSyncExtensionPoint = (CDMFrSyncExtensionPoint) manager.lookup(CDMFrSyncExtensionPoint.ROLE);
251        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
252        _odfRefTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
253        _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
254        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
255        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
256        _rootOUProvider = (RootOrgUnitProvider) manager.lookup(RootOrgUnitProvider.ROLE);
257        _synchroComponent = (BaseSynchroComponent) manager.lookup(BaseSynchroComponent.ROLE);
258        _shareableCourseHelper = (ShareableCourseHelper) manager.lookup(ShareableCourseHelper.ROLE);
259    }
260
261    @Override
262    public String getIdField()
263    {
264        return "cdmfrSyncCode";
265    }
266
267    /**
268     * Get the synchronized metadata from the configuration file
269     * @throws ConfigurationException if the configuration is not valid.
270     */
271    private void _parseSynchronizedFields() throws ConfigurationException
272    {
273        _syncFieldsByContentType = new HashMap<>();
274        
275        @SuppressWarnings("resource")
276        InputStream is = null;
277        try
278        {
279            File apogeeMapping = new File(_cocoonContext.getRealPath("/WEB-INF/param/odf-synchro.xml"));
280            if (!apogeeMapping.isFile())
281            {
282                is = getClass().getResourceAsStream("/org/ametys/plugins/odfsync/cdmfr/odf-synchro.xml");
283            }
284            else
285            {
286                is = new FileInputStream(apogeeMapping);
287            }
288            Configuration cfg = new DefaultConfigurationBuilder().build(is);
289
290            Configuration[] cTypesConf = cfg.getChildren("content-type");
291            for (Configuration cTypeConf : cTypesConf)
292            {
293                String contentType = cTypeConf.getAttribute("id");
294                Set<String> syncMetadata = _configureSynchronizedFields(cTypeConf, "");
295                _syncFieldsByContentType.put(contentType, syncMetadata);
296            }
297        }
298        catch (Exception e)
299        {
300            throw new ConfigurationException("Error while parsing odf-synchro.xml", e);
301        }
302        finally
303        {
304            IOUtils.closeQuietly(is);
305        }
306    }
307
308    private Set<String> _configureSynchronizedFields(Configuration configuration, String prefix) throws ConfigurationException
309    {
310        Set<String> syncMetadata = new HashSet<>();
311        Configuration[] metaConf = configuration.getChildren("metadata");
312        
313        if (metaConf.length > 0)
314        {
315            for (Configuration metadata : metaConf)
316            {
317                if (metadata.getChildren("metadata").length > 0)
318                {
319                    // composite
320                    syncMetadata.addAll(_configureSynchronizedFields(metadata, prefix + metadata.getAttribute("name") + "/"));
321                }
322                else
323                {
324                    syncMetadata.add(prefix + metadata.getAttribute("name"));
325                }
326            }
327        }
328        else if (configuration.getAttribute("name", null) != null)
329        {
330            syncMetadata.add(prefix + configuration.getAttribute("name"));
331        }
332        
333        return syncMetadata;
334    }
335    
336    @Override
337    @SuppressWarnings("unchecked")
338    public synchronized Map<String, Object> handleInputStream(InputStream input, Map<String, Object> parameters, Logger logger) throws ProcessingException
339    {
340        List<ModifiableDefaultContent> importedPrograms = new ArrayList<>();
341        
342        _importedContents = new HashMap<>();
343        _synchronizedContents = new HashSet<>();
344        _updatedContents = (Set<String>) parameters.getOrDefault("updatedContents", new HashSet<>());
345        _nbCreatedContents = (int) parameters.getOrDefault("nbCreatedContents", 0);
346        _nbSynchronizedContents = (int) parameters.getOrDefault("nbSynchronizedContents", 0);
347        _nbNotChangedContents = (int) parameters.getOrDefault("nbNotChangedContents", 0);
348        _nbError = (int) parameters.getOrDefault("nbError", 0);
349        _contentPrefix = (String) parameters.getOrDefault("contentPrefix", "cdmfr-");
350        additionalParameters(parameters);
351
352        Map<String, Object> resultMap = new HashMap<>();
353        
354        try
355        {
356            Document doc = _domParser.parseDocument(new InputSource(input));
357            doc = transformDocument(doc, new HashMap<String, Object>(), logger);
358
359            if (doc != null)
360            {
361                String defaultLang = _getXPathString(doc, "CDM/@language", _odfLang);
362                
363                NodeList nodes = doc.getElementsByTagName(_TAG_PROGRAM);
364                
365                for (int i = 0; i < nodes.getLength(); i++)
366                {
367                    Node contentNode = nodes.item(i);
368                    String syncCode = _xPathProcessor.evaluateAsString(contentNode, "@CDMid");
369                    String contentLang = _getXPathString(contentNode, "@language", defaultLang);
370                    contentLang = StringUtils.substring(contentLang, 0, 2).toLowerCase(); // on keep the language from the locale
371                    
372                    String catalog = getCatalogName(contentNode);
373
374                    importedPrograms.add(_importOrSynchronizeContent(doc, contentNode, getProgramWfDescription(), syncCode, contentLang, catalog, syncCode, logger));
375                }
376                
377                // Apply changes (synchronize action) -> only on newly synchronized contents
378                boolean ignoreRights = ignoreRights();
379                for (String contentId : _synchronizedContents)
380                {
381                    ModifiableDefaultContent content = _resolver.resolveById(contentId);
382                    _synchroComponent.applyChanges(content, SYNCHRONIZE_WORKFLOW_ACTION_ID, org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_SYNCHRONIZED, ignoreRights, logger);
383                }
384                
385                // Validate contents -> only on newly imported contents
386                if (validateAfterImport())
387                {
388                    for (String contentId : _importedContents.keySet())
389                    {
390                        ModifiableDefaultContent content = _resolver.resolveById(contentId);
391                        Integer validationActionId = _importedContents.get(contentId);
392                        if (validationActionId > 0)
393                        {
394                            _synchroComponent.validateContent(content, validationActionId, ignoreRights, logger);
395                        }
396                    }
397                }
398            }
399        }
400        catch (IOException | ProcessingException e)
401        {
402            throw new ProcessingException("An error occured while transforming the stream.", e);
403        }
404        catch (SAXException e)
405        {
406            throw new ProcessingException("An error occured while parsing the stream.", e);
407        }
408        catch (RepositoryException e)
409        {
410            throw new ProcessingException("An error occured while applying changes on contents.", e);
411        }
412
413        resultMap.put("importedContents", _importedContents.keySet());
414        resultMap.put("synchronizedContents", _synchronizedContents);
415        resultMap.put("updatedContents", _updatedContents);
416        resultMap.put("nbCreatedContents", _nbCreatedContents);
417        resultMap.put("nbSynchronizedContents", _nbSynchronizedContents);
418        resultMap.put("nbNotChangedContents", _nbNotChangedContents);
419        resultMap.put("nbError", _nbError);
420        resultMap.put("importedPrograms", importedPrograms);
421
422        return resultMap;
423    }
424
425    /**
426     * True to validate the contents after import
427     * @return True to validate the contents after import
428     */
429    protected abstract boolean validateAfterImport();
430
431    /**
432     * When returns true, a content created by a previous synchro will be removed if it does not exist anymore during the current synchro.
433     * @return true if a content created by a previous synchro has to be removed if it does not exist anymore during the current synchro.
434     */
435    protected abstract boolean removalSync();
436    
437    /**
438     * Transform the document depending of it structure.
439     * @param document Document to transform.
440     * @param parameters Optional parameters for transformation
441     * @param logger The logger
442     * @return The transformed document.
443     * @throws IOException if an error occurs.
444     * @throws SAXException if an error occurs.
445     * @throws ProcessingException if an error occurs.
446     */
447    protected Document transformDocument(Document document, Map<String, Object> parameters, Logger logger) throws IOException, SAXException, ProcessingException
448    {
449        CDMFrSyncTransformer transformer = _cdmFrSyncExtensionPoint.getTransformer(document);
450        if (transformer == null)
451        {
452            logger.error("Cannot match a CDM-fr transformer to this file structure.");
453            return null;
454        }
455        
456        return transformer.transform(document, parameters);
457    }
458
459    /**
460     * Get the name of catalog to use for import
461     * @param contentNode The node of program
462     * @return The catalog to used
463     */
464    protected String getCatalogName(Node contentNode)
465    {
466        String defaultCatalog = _catalogsManager.getDefaultCatalogName();
467        
468        String contentCatalog = _getXPathString(contentNode, "catalog", defaultCatalog);
469        if (_catalogsManager.getCatalog(contentCatalog) == null)
470        {
471            // Catalog is empty or do not exist, use the default catalog
472            return defaultCatalog;
473        }
474        
475        return contentCatalog;
476    }
477    
478    /**
479     * Get or create the content from the synchronization code, the lang, the catalog and the content type.
480     * @param title The title
481     * @param lang The lang
482     * @param catalog The catalog
483     * @param syncCode The synchronization code
484     * @param wfDescription The workflow description
485     * @param logger The logger
486     * @return the retrieved or created content
487     * @throws RepositoryException if an error occurs
488     */
489    protected ModifiableDefaultContent _getOrCreateContent(String title, String lang, String catalog, String syncCode, ContentWorkflowDescription wfDescription, Logger logger) throws RepositoryException
490    {
491        ModifiableDefaultContent receivedContent = _getContent(lang, catalog, syncCode, wfDescription);
492        if (receivedContent != null)
493        {
494            return receivedContent;
495        }
496        
497        Map<String, Object> resultMap = _synchroComponent.createContentAction(wfDescription.getContentType(), wfDescription.getWorkflowName(), wfDescription.getInitialActionId(), lang, title, _contentPrefix, logger);
498        if ((boolean) resultMap.getOrDefault("error", false))
499        {
500            _nbError++;
501        }
502        
503        ModifiableDefaultContent content = (ModifiableDefaultContent) resultMap.get("content");
504        
505        if (content != null)
506        {
507            ExternalizableMetadataHelper.setMetadata(content.getMetadataHolder(), getIdField(), syncCode);
508            if (catalog != null && (content instanceof ProgramItem || content instanceof CoursePart))
509            {
510                ExternalizableMetadataHelper.setMetadata(content.getMetadataHolder(), ProgramItem.CATALOG, catalog);
511            }
512            additionalOperationsBeforeSave(content, logger);
513            content.saveChanges();
514            _importedContents.put(content.getId(), wfDescription.getValidationActionId());
515            _nbCreatedContents++;
516        }
517        return content;
518    }
519
520    /**
521     * Get the content from the synchronization code, the lang, the catalog and the content type.
522     * @param lang The lang
523     * @param catalog The catalog
524     * @param syncCode The synchronization code
525     * @param wfDescription The workflow description
526     * @return the retrieved content
527     */
528    protected ModifiableDefaultContent _getContent(String lang, String catalog, String syncCode, ContentWorkflowDescription wfDescription)
529    {
530        List<Expression> expList = getExpressionsList(lang, syncCode, wfDescription.getContentType(), catalog);
531        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
532        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
533
534        AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(xPathQuery);
535
536        if (contents.getSize() > 0)
537        {
538            return contents.iterator().next();
539        }
540        
541        return null;
542    }
543    
544    /**
545     * Additional parameters for specific treatments.
546     * @param parameters The parameters map to get
547     */
548    protected abstract void additionalParameters(Map<String, Object> parameters);
549    
550    /**
551     * Additional operation to do on the content before saving it.
552     * @param content The content
553     * @param logger The logger
554     * @throws RepositoryException if an error occurs
555     */
556    protected abstract void additionalOperationsBeforeSave(ModifiableDefaultContent content, Logger logger) throws RepositoryException;
557    
558    /**
559     * Import or synchronize the content.
560     * @param doc XML document
561     * @param contentNode Node of the content
562     * @param wfDescription The workflow description
563     * @param title The title
564     * @param lang The lang
565     * @param catalog The catalog
566     * @param syncCode The synchronization code
567     * @param logger The logger
568     * @return The imported or synchronized content
569     */
570    protected ModifiableDefaultContent _importOrSynchronizeContent(Document doc, Node contentNode, ContentWorkflowDescription wfDescription, String title, String lang, String catalog, String syncCode, Logger logger)
571    {
572        ModifiableDefaultContent content = null;
573        try
574        {
575            content = _getOrCreateContent(title, lang, catalog, syncCode, wfDescription, logger);
576            if (content != null)
577            {
578                _synchronizeContent(doc, contentNode, content, wfDescription.getContentType(), lang, catalog, syncCode, logger);
579            }
580        }
581        catch (RepositoryException e)
582        {
583            _nbError++;
584            logger.error("An error occurred while importing or synchronizing content", e);
585        }
586        return content;
587    }
588    
589    /**
590     * Synchronize content 
591     * @param doc The root document
592     * @param contentNode the DOM content node
593     * @param content The content to synchronize
594     * @param contentTypeId The content type ID
595     * @param lang Parent program language (to select the good courses in the CDM-FR file)
596     * @param catalog The catalog of parent program
597     * @param syncCode The synchronization code
598     * @param logger The logger
599     */
600    protected void _synchronizeContent(Document doc, Node contentNode, ModifiableDefaultContent content, String contentTypeId, String lang, String catalog, String syncCode, Logger logger)
601    {
602        if (_updatedContents.add(content.getId()))
603        {
604            logger.info("Synchronization of the content '{}' with the content type '{}'", content.getTitle(), contentTypeId);
605            
606            List<ModifiableDefaultContent> children = new LinkedList<>();
607            List<ModifiableDefaultContent> courseParts = new LinkedList<>();
608            boolean removeOldCourseParts = false;
609            
610            if (content.isLocked())
611            {
612                logger.warn("The content '{}' ({}) is currently locked by user {}: it cannot be synchronized", content.getTitle(), content.getId(), content.getLockOwner());
613            }
614            else
615            {
616                boolean hasChanges = false;
617                ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
618                int courseListPosition = 0;
619                
620                NodeList metadataNodes = contentNode.getChildNodes();
621                for (int i = 0; i < metadataNodes.getLength(); i++)
622                {
623                    Node metadataNode = metadataNodes.item(i);
624                    String metadataName = metadataNode.getLocalName();
625                    
626                    if (metadataName.equals(_TAG_SUBPROGRAM))
627                    {
628                        String subContentSyncCode = _xPathProcessor.evaluateAsString(metadataNode, "@CDMid");
629                        String title = _xPathProcessor.evaluateAsString(metadataNode, "title");
630                        ModifiableDefaultContent subProgram = _importOrSynchronizeContent(doc, metadataNode, getSubProgramWfDescription(), title, lang, catalog, subContentSyncCode, logger);
631                        CollectionUtils.addIgnoreNull(children, subProgram);
632                    }
633                    else if (metadataName.equals(_TAG_CONTAINER))
634                    {
635                        String subContentSyncCode = _xPathProcessor.evaluateAsString(metadataNode, "@CDMid");
636                        String title = _xPathProcessor.evaluateAsString(metadataNode, "title");
637                        ModifiableDefaultContent container = _importOrSynchronizeContent(doc, metadataNode, getContainerWfDescription(), title, lang, catalog, subContentSyncCode, logger);
638                        CollectionUtils.addIgnoreNull(children, container);
639                    }
640                    else if (metadataName.equals(_TAG_COURSELIST))
641                    {
642                        courseListPosition++;
643                        // For courseList from another source than Ametys, there is no unique code, then a code is generated with the parent ID and the position in the parent : [parentId]-[position]
644                        String subContentSyncCode = _getXPathString(metadataNode, "@code", syncCode + "-" + courseListPosition);
645                        String title = _getXPathString(metadataNode, "@name", "Liste d'éléments pédagogiques");
646                        ModifiableDefaultContent courseList = _importOrSynchronizeContent(doc, metadataNode, getCourseListWfDescription(), title, lang, catalog, subContentSyncCode, logger);
647                        if (courseList != null)
648                        {
649                            _synchronizeCourseList(doc, metadataNode, courseList, lang, catalog, logger);
650                            children.add(courseList);
651                        }
652                    }
653                    else if (metadataName.equals(_TAG_COURSEPART))
654                    {
655                        String subContentSyncCode = _xPathProcessor.evaluateAsString(metadataNode, "code");
656                        if (StringUtils.isEmpty(subContentSyncCode))
657                        {
658                            subContentSyncCode = org.ametys.core.util.StringUtils.generateKey().toUpperCase();
659                            removeOldCourseParts = true;
660                        }
661                        String title = _xPathProcessor.evaluateAsString(metadataNode, "title");
662                        ModifiableDefaultContent coursePart = _importOrSynchronizeContent(doc, metadataNode, getCoursePartWfDescription(), title, lang, catalog, subContentSyncCode, logger);
663                        CollectionUtils.addIgnoreNull(courseParts, coursePart);
664                    }
665                    // Explicitely ignore catalog metadata which is forced at content creation
666                    else if (!metadataName.equals(ProgramItem.CATALOG))
667                    {
668                        hasChanges = _synchronizeMetadata(doc, metadataNode, content, metadataName, metadataName, contentType, lang, catalog, logger) || hasChanges;
669                    }
670                }
671                
672                hasChanges = _setRelations(content, children, courseParts, removeOldCourseParts, logger) || hasChanges;
673                
674                // Create translation links
675                _linkTranslationsIfExist(content, contentTypeId);
676                
677                _saveContentChanges(content, contentTypeId, hasChanges, logger);
678            }
679        }
680    }
681
682    /**
683     * Synchronize a course list, it has attributes to synchronize.
684     * @param doc The XML document
685     * @param courseListNode The XML node of the course list
686     * @param courseList The course list content
687     * @param lang The lang
688     * @param catalog The catalog
689     * @param logger The logger
690     */
691    protected void _synchronizeCourseList(Document doc, Node courseListNode, ModifiableDefaultContent courseList, String lang, String catalog, Logger logger)
692    {
693        boolean hasChanges = false;
694        
695        ContentType contentType = _contentTypeEP.getExtension(getCourseListWfDescription().getContentType());
696        NamedNodeMap attributes = courseListNode.getAttributes();
697        for (int i = 0; i < attributes.getLength(); i++)
698        {
699            Node attributeNode = attributes.item(i);
700            String attributeName = attributeNode.getLocalName();
701            // Explicitely ignore catalog metadata which is forced at content creation
702            if (!attributeName.equals(ProgramItem.CATALOG))
703            {
704                hasChanges = _synchronizeMetadata(doc, attributeNode, courseList, attributeName, attributeName, contentType, lang, catalog, logger) || hasChanges;
705            }
706        }
707
708        List<ModifiableDefaultContent> courses = new LinkedList<>();
709        NodeList itemNodes = courseListNode.getChildNodes();
710        for (int i = 0; i < itemNodes.getLength(); i++)
711        {
712            String syncCode = itemNodes.item(i).getTextContent().trim();
713            
714            Node courseNode = _xPathProcessor.selectSingleNode(doc.getFirstChild(), "course[@CDMid = '" + syncCode + "' and @language = '" + lang + "']");
715            if (courseNode == null)
716            {
717                courseNode = _xPathProcessor.selectSingleNode(doc.getFirstChild(), "course[@CDMid = '" + syncCode + "']");
718            }
719            
720            if (courseNode != null)
721            {
722                String elpLang = _getXPathString(courseNode, "@language", lang);
723
724                // Check the catalog is the same as the parent program
725                String courseCatalog = getCatalogName(courseNode);
726                if (courseCatalog != null && !courseCatalog.equals(catalog))
727                {
728                    String cdmCode = _xPathProcessor.evaluateAsString(courseNode, "cdmCode");
729                    logger.error("The course '{}' belongs to a different catalog than the one from the imported/synchronized program : '{}' vs '{}'. No synchronization will be done on this course.", cdmCode, courseCatalog, catalog);
730                }
731                else
732                {
733                    String title = _xPathProcessor.evaluateAsString(courseNode, "title");
734                    ModifiableDefaultContent course = _importOrSynchronizeContent(doc, courseNode, getCourseWfDescription(), title, elpLang, catalog, syncCode, logger);
735                    if (course != null)
736                    {
737                        courses.add(course);
738                        hasChanges = _shareableCourseHelper.initializeShareableFields((Course) course, (CourseList) courseList,  UserPopulationDAO.SYSTEM_USER_IDENTITY, true) || hasChanges;
739                    }
740                }
741            }
742        }
743        
744        if (!courses.isEmpty())
745        {
746            hasChanges = ExternalizableMetadataHelper.setMetadata(courseList.getMetadataHolder(), CourseList.CHILD_COURSES, courses.toArray(new ModifiableDefaultContent[courses.size()])) || hasChanges;
747            for (ModifiableDefaultContent course : courses)
748            {
749                hasChanges = _synchroComponent.updateRelation(course.getMetadataHolder(), Course.PARENT_COURSE_LISTS, courseList, false) || hasChanges;
750            }
751        }
752        
753        _saveContentChanges(courseList, getCourseListWfDescription().getContentType(), hasChanges, logger);
754    }
755
756    private void _fetchImages(Node docbookNode, ModifiableDefaultContent content, ModifiableRichText richText, String metadataPath, Logger logger)
757    {
758        NodeList unfetchUrls = _xPathProcessor.selectNodeList(docbookNode, ".//docbook:mediaobject/docbook:imageobject/docbook:imagedata", _PREFIX_RESOLVER);
759        
760        for (int i = 0; i < unfetchUrls.getLength(); i++)
761        {
762            Element href = (Element) unfetchUrls.item(i);
763            String imageUrl = href.getAttribute("fileref");
764            
765            if (CommentsDAO.URL_VALIDATOR.matcher(imageUrl).matches())
766            {
767                @SuppressWarnings("resource")
768                InputStream is = null;
769                try
770                {
771                    URL url = new URL(imageUrl);
772                    is = url.openStream();
773                    
774                    String path = url.getPath();
775                    String fileName = path.substring(path.lastIndexOf("/") + 1);
776                    
777                    ModifiableFile file = null;
778                    if (richText.getAdditionalDataFolder().hasFile(fileName))
779                    {
780                        file = richText.getAdditionalDataFolder().getFile(fileName);
781                    }
782                    else
783                    {
784                        file = richText.getAdditionalDataFolder().addFile(fileName); 
785                    }
786                    
787                    ModifiableResource resource = file.getResource();
788                    resource.setInputStream(is);
789                    resource.setLastModified(new Date());
790                    
791                    String mimeType = _cocoonContext.getMimeType(fileName);
792                    if (mimeType == null)
793                    {
794                        mimeType = "application/unknown";
795                    }
796                    resource.setMimeType(mimeType);
797                    
798                    String fetchUrl = content.getId() + "@" + metadataPath + ";" + fileName;
799                    href.setAttribute("fileref", fetchUrl);
800                }
801                catch (Exception e)
802                {
803                    logger.warn("Unable to retrieve remote image '{}'.", imageUrl, e);
804                }
805                finally
806                {
807                    IOUtils.closeQuietly(is);
808                }
809            }
810        }
811    }
812    
813    /**
814     * Synchronize a metadata (can be a composite or a repeater).
815     * @param doc The XML document
816     * @param metadataNode The metadata node
817     * @param content The content
818     * @param logicalMetadataPath The logical metadata path (to retrieve the definition)
819     * @param completeMetadataPath The complete metadata path (to retrieve the metadata holder)
820     * @param contentType The content type
821     * @param lang The lang
822     * @param catalog The catalog
823     * @param logger The logger
824     * @return <code>true</code> if changes occurs
825     */
826    protected boolean _synchronizeMetadata(Document doc, Node metadataNode, ModifiableDefaultContent content, String logicalMetadataPath, String completeMetadataPath, ContentType contentType, String lang, String catalog, Logger logger)
827    {
828        List<String> untypedValues = null;
829        MetadataDefinition metadataDef = contentType.getMetadataDefinitionByPath(logicalMetadataPath);
830        Map<String, Object> params = Map.of("contentTypes", List.of(contentType.getId()));
831        boolean synchronize = getLocalAndExternalFields(params).contains(logicalMetadataPath);
832        if (metadataDef != null)
833        {
834            if (metadataDef instanceof RepeaterDefinition)
835            {
836                return _handleRepeaterMetadata(doc, metadataNode, content, logicalMetadataPath, completeMetadataPath, contentType, lang, catalog, logger);
837            }
838            else if (metadataDef.getType() == MetadataType.COMPOSITE)
839            {
840                return _handleCompositeMetadata(doc, metadataNode, content, logicalMetadataPath, completeMetadataPath, contentType, lang, catalog, logger);
841            }
842            else if (metadataDef.getType() == MetadataType.RICH_TEXT)
843            {
844                return _handleRichTextMetadata(content, logicalMetadataPath, completeMetadataPath, metadataNode, synchronize, logger);
845            }
846            else if (metadataDef.getType() == MetadataType.BINARY)
847            {
848                return _handleBinaryMetadata(metadataNode, content, completeMetadataPath, synchronize, logger);
849            }
850            else if (metadataDef.getType() == MetadataType.FILE)
851            {
852                return _handleFileMetadata(metadataNode, content, logicalMetadataPath, completeMetadataPath, synchronize, contentType, logger);
853            }
854            else if (metadataDef.getType() == MetadataType.GEOCODE)
855            {
856                return _handleGeocodeMetadata(metadataNode, content, completeMetadataPath, synchronize);
857            }
858            else if (metadataDef.isMultiple())
859            {
860                NodeList itemNodes = metadataNode.getChildNodes();
861                untypedValues = new ArrayList<>();
862                for (int j = 0; j < itemNodes.getLength(); j++)
863                {
864                    String metadataValue = itemNodes.item(j).getTextContent().trim();
865                    if (StringUtils.isNotEmpty(metadataValue))
866                    {
867                        untypedValues.add(metadataValue);
868                    }
869                }
870            }
871            else
872            {
873                String metadataValue = metadataNode.getTextContent().trim();
874                if (StringUtils.isNotEmpty(metadataValue))
875                {
876                    untypedValues = List.of(metadataValue);
877                }
878            }
879        }
880
881        Map<String, Boolean> resultMap = _synchroComponent.synchronizeMetadata(content, contentType, logicalMetadataPath, completeMetadataPath, _handleMetadataValues(doc, content, metadataDef, untypedValues, lang, catalog, logger), synchronize, _importedContents.containsKey(content.getId()), logger);
882        if (resultMap.getOrDefault("error", Boolean.FALSE))
883        {
884            _nbError++;
885        }
886        return resultMap.getOrDefault("hasChanges", Boolean.FALSE).booleanValue();
887    }
888    
889    /**
890     * Synchronize geocode metadata
891     * @param metadataNode The metadata node
892     * @param content The content
893     * @param completeMetadataPath The complete metadata path (to retrieve the metadata holder)
894     * @param synchronize true if the data is external
895     * @return true if some changes has been made
896     */
897    protected boolean _handleGeocodeMetadata(Node metadataNode, ModifiableDefaultContent content, String completeMetadataPath, boolean synchronize)
898    {
899        ModifiableCompositeMetadata metadataHolder = _synchroComponent.getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
900        String[] arrayPath = completeMetadataPath.split("/");
901        String metadataName = arrayPath[arrayPath.length - 1];
902        
903        return _handleGeocodeMetadata(metadataNode, content, metadataHolder, metadataName, synchronize);
904    }
905    
906    /**
907     * Synchronize geocode metadata
908     * @param metadataNode The metadata node
909     * @param content The content
910     * @param metadataHolder the metadata holder
911     * @param metadataName the metadata name
912     * @param synchronize true if the data is external
913     * @return true if some changes has been made
914     */
915    protected boolean _handleGeocodeMetadata(Node metadataNode, ModifiableDefaultContent content, ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize)
916    {
917        if (!metadataNode.hasChildNodes())
918        {
919            return _synchroComponent.removeMetadataIfExists(metadataHolder, metadataName, synchronize);
920        }
921
922        boolean hasChanges = false;
923        ExternalizableMetadataStatus status = synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL;
924        ModifiableCompositeMetadata geoCode = ExternalizableMetadataHelper.getCompositeMetadata(metadataHolder, metadataName, status, true);
925        if (synchronize && _importedContents.containsKey(content.getId()))
926        {
927            hasChanges = ExternalizableMetadataHelper.updateStatus(metadataHolder, metadataName, status) || hasChanges;
928        }
929        
930        NodeList coordinates = metadataNode.getChildNodes();
931        for (int j = 0; j < coordinates.getLength(); j++)
932        {
933            Node coordinate = coordinates.item(j);
934            Double coordinateValue = Double.valueOf(coordinate.getTextContent().trim());
935            hasChanges = ExternalizableMetadataHelper.setMetadata(geoCode, coordinate.getLocalName(), coordinateValue) || hasChanges;
936        }
937        return hasChanges;
938    }
939
940    /**
941     * Synchronize file metadata
942     * @param metadataNode The metadata node
943     * @param content The content
944     * @param logicalMetadataPath The logical metadata path (to retrieve the definition) 
945     * @param completeMetadataPath The complete metadata path (to retrieve the metadata holder)
946     * @param synchronize true if the data is external
947     * @param contentType the content type
948     * @param logger the logger
949     * @return true if some changes has been made
950     */
951    protected boolean _handleFileMetadata(Node metadataNode, ModifiableDefaultContent content, String logicalMetadataPath, String completeMetadataPath, boolean synchronize, ContentType contentType, Logger logger)
952    {
953        ModifiableCompositeMetadata metadataHolder = _synchroComponent.getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
954        String[] arrayPath = completeMetadataPath.split("/");
955        String metadataName = arrayPath[arrayPath.length - 1];
956        
957        return _handleFileMetadata(metadataNode, content, logicalMetadataPath, metadataHolder, metadataName, synchronize, contentType, logger);
958    }
959    
960    /**
961     * Synchronize file metadata
962     * @param metadataNode The metadata node
963     * @param content The content
964     * @param logicalMetadataPath The logical metadata path (to retrieve the definition) 
965     * @param metadataHolder the metadata holder
966     * @param metadataName the metadata name
967     * @param synchronize true if the data is external
968     * @param contentType the content type
969     * @param logger the logger
970     * @return true if some changes has been made
971     */
972    protected boolean _handleFileMetadata(Node metadataNode, ModifiableDefaultContent content, String logicalMetadataPath, ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize, ContentType contentType, Logger logger)
973    {
974        boolean hasChanges = false;
975        String value = metadataNode.getTextContent().trim();
976        boolean metadataExists = metadataHolder.hasMetadata(metadataName);
977        CompositeMetadata.MetadataType metadataType = metadataExists ? metadataHolder.getType(metadataName) : null;
978        if (CommentsDAO.URL_VALIDATOR.matcher(value).matches())
979        {
980            if (metadataExists && metadataType != CompositeMetadata.MetadataType.BINARY)
981            {
982                hasChanges = _synchroComponent.removeMetadataIfExists(metadataHolder, metadataName, synchronize) || hasChanges;
983            }
984            hasChanges = _handleBinaryMetadata(metadataNode, content, metadataHolder, metadataName, synchronize, logger) || hasChanges;
985        }
986        else
987        {
988            if (metadataExists && metadataType != CompositeMetadata.MetadataType.STRING)
989            {
990                hasChanges = _synchroComponent.removeMetadataIfExists(metadataHolder, metadataName, synchronize) || hasChanges;
991            }
992            
993            Map<String, Boolean> resultMap = _synchroComponent.synchronizeMetadata(content, contentType, logicalMetadataPath, metadataHolder, metadataName, List.of(value), synchronize, _importedContents.containsKey(content.getId()), logger);
994            if (resultMap.getOrDefault("error", Boolean.FALSE))
995            {
996                _nbError++;
997            }
998            hasChanges = resultMap.getOrDefault("hasChanges", Boolean.FALSE).booleanValue() || hasChanges;
999        }
1000        return hasChanges;
1001    }
1002    
1003    /**
1004     * Synchronize binary metadata
1005     * @param metadataNode The metadata node
1006     * @param content The content
1007     * @param completeMetadataPath The complete metadata path (to retrieve the metadata holder)
1008     * @param synchronize true if the data is external
1009     * @param logger the logger
1010     * @return true if some changes has been made
1011     */
1012    protected boolean _handleBinaryMetadata(Node metadataNode, ModifiableDefaultContent content, String completeMetadataPath, boolean synchronize, Logger logger)
1013    {
1014        ModifiableCompositeMetadata metadataHolder = _synchroComponent.getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
1015        String[] arrayPath = completeMetadataPath.split("/");
1016        String metadataName = arrayPath[arrayPath.length - 1];
1017        
1018        return _handleBinaryMetadata(metadataNode, content, metadataHolder, metadataName, synchronize, logger);
1019    }
1020    
1021    /**
1022     * Synchronize binary metadata
1023     * @param metadataNode The metadata node
1024     * @param content The content
1025     * @param metadataHolder the metadata holder
1026     * @param metadataName the metadata name
1027     * @param synchronize true if the data is external
1028     * @param logger the logger
1029     * @return true if some changes has been made
1030     */
1031    protected boolean _handleBinaryMetadata(Node metadataNode, ModifiableDefaultContent content, ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize, Logger logger)
1032    {
1033        boolean hasChanges = false;
1034        
1035        String value = metadataNode.getTextContent().trim();
1036        
1037        if (StringUtils.isEmpty(value))
1038        {
1039            return _synchroComponent.removeMetadataIfExists(metadataHolder, metadataName, synchronize);
1040        }
1041        
1042        try
1043        {
1044            URL url = new URL(value);
1045            
1046            byte[] bytes;
1047            try (InputStream is = url.openStream())
1048            {
1049                bytes = IOUtils.toByteArray(is);
1050            }
1051
1052            ExternalizableMetadataStatus status = synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL;
1053            ModifiableBinaryMetadata binaryMetadata = ExternalizableMetadataHelper.getBinaryMetadata(metadataHolder, metadataName, status, true);
1054
1055            byte[] oldValue = new byte[0];
1056            try (InputStream is = binaryMetadata.getInputStream())
1057            {
1058                oldValue = IOUtils.toByteArray(is);
1059            }
1060            catch (AmetysRepositoryException e)
1061            {
1062                logger.debug("The old value of '{}' should not be initialized on the content '{}'.", metadataName, content.getId(), e);
1063            }
1064
1065            if (!Objects.deepEquals(bytes, oldValue))
1066            {
1067                String path = url.getPath();
1068                String filename = path.substring(path.lastIndexOf("/") + 1);
1069                String mimeType = _cocoonContext.getMimeType(filename);
1070                if (mimeType == null)
1071                {
1072                    mimeType = "application/unknown";
1073                }
1074                binaryMetadata.setFilename(URIUtils.decode(filename));
1075                binaryMetadata.setMimeType(mimeType);
1076                binaryMetadata.setLastModified(new Date());
1077                binaryMetadata.setInputStream(new ByteArrayInputStream(bytes));
1078    
1079                hasChanges = true;
1080            }
1081
1082            if (synchronize && _importedContents.containsKey(content.getId()))
1083            {
1084                hasChanges = ExternalizableMetadataHelper.updateStatus(metadataHolder, metadataName, status) || hasChanges;
1085            }
1086        }
1087        catch (IOException e)
1088        {
1089            logger.error("Unable to retrieve remote file input stream", e);
1090        }
1091
1092        return hasChanges;
1093    }
1094    
1095    private boolean _handleRepeaterMetadata(Document doc, Node metadataNode, ModifiableDefaultContent content, String logicalMetadataPath, String completeMetadataPath, ContentType contentType, String lang, String catalog, Logger logger)
1096    {
1097        boolean hasChanges = false;
1098        
1099        ModifiableCompositeMetadata repeater;
1100        if (completeMetadataPath.indexOf("/") == -1)
1101        {
1102            repeater = content.getMetadataHolder().getCompositeMetadata(completeMetadataPath, true);
1103        }
1104        else
1105        {
1106            ModifiableCompositeMetadata metadataHolder = _synchroComponent.getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
1107            repeater = metadataHolder.getCompositeMetadata(StringUtils.substringAfterLast(completeMetadataPath, "/"), true);
1108        }
1109        
1110        // Remove locale entries (unable to compare locales and remotes ...)
1111        String[] metadataNames = repeater.getMetadataNames();
1112        for (String entryName : metadataNames)
1113        {
1114            repeater.removeMetadata(entryName);
1115            hasChanges = true;
1116        }
1117        
1118        // Create new entries from remote data
1119        NodeList entryNodes = metadataNode.getChildNodes();
1120        for (int i = 0; i < entryNodes.getLength(); i++)
1121        {
1122            Node entryNode = entryNodes.item(i);
1123            String entryName = entryNode.getAttributes().getNamedItem("name").getTextContent().trim();
1124            
1125            NodeList childNodes = entryNode.getChildNodes();
1126            for (int j = 0; j < childNodes.getLength(); j++)
1127            {
1128                Node childNode = childNodes.item(j);
1129                String subMetadataName = childNode.getLocalName();
1130                
1131                hasChanges = _synchronizeMetadata(doc, childNode, content, logicalMetadataPath + "/" + subMetadataName, completeMetadataPath + "/" + entryName + "/" + subMetadataName, contentType, lang, catalog, logger) || hasChanges;
1132            }
1133        }
1134        
1135        return hasChanges;
1136    }
1137    
1138    private boolean _handleCompositeMetadata(Document doc, Node metadataNode, ModifiableDefaultContent content, String logicalMetadataPath, String completeMetadataPath, ContentType contentType, String lang, String catalog, Logger logger)
1139    {
1140        boolean hasChanges = false;
1141        NodeList subMetadataNodes = metadataNode.getChildNodes();
1142        for (int i = 0; i < subMetadataNodes.getLength(); i++)
1143        {
1144            Node subMetadataNode = subMetadataNodes.item(i);
1145            hasChanges = _synchronizeMetadata(doc, subMetadataNode, content, logicalMetadataPath + "/" + subMetadataNode.getLocalName(), completeMetadataPath + "/" + subMetadataNode.getLocalName(), contentType, lang, catalog, logger) || hasChanges;
1146        }
1147        return hasChanges;
1148    }
1149    
1150    private boolean _handleRichTextMetadata(ModifiableDefaultContent content, String logicalMetadataPath, String completeMetadataPath, Node metadataNode, boolean synchronize, Logger logger)
1151    {
1152        ModifiableCompositeMetadata metadataHolder = _synchroComponent.getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
1153        String[] arrayPath = completeMetadataPath.split("/");
1154        String metadataName = arrayPath[arrayPath.length - 1];
1155
1156        if (metadataNode.hasChildNodes())
1157        {
1158            boolean hasChanges = false;
1159            try
1160            {
1161                ExternalizableMetadataStatus status = synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL;
1162                ModifiableRichText richText = ExternalizableMetadataHelper.getRichText(metadataHolder, metadataName, status, true);
1163                
1164                _fetchImages(metadataNode.getFirstChild(), content, richText, completeMetadataPath, logger);
1165                
1166                String docbook = ContentImporterHelper.serializeNode(metadataNode.getFirstChild());
1167                try (ByteArrayInputStream is =  new ByteArrayInputStream(docbook.getBytes("UTF-8")))
1168                {
1169                    richText.setInputStream(is);
1170                    richText.setMimeType("text/xml");
1171                    richText.setLastModified(new Date()); 
1172
1173                    if (synchronize && _importedContents.containsKey(content.getId()))
1174                    {
1175                        hasChanges = ExternalizableMetadataHelper.updateStatus(metadataHolder, metadataName, status) || hasChanges;
1176                    }
1177                    
1178                    hasChanges = true;
1179                }
1180                catch (IOException e)
1181                {
1182                    logger.error("An error occured while parsing the rich text '{}' of the content '{}'", logicalMetadataPath, content.getTitle(), e);
1183                }
1184            }
1185            catch (TransformerException e)
1186            {
1187                logger.error("Error serializing a docbook node.", e);
1188            }
1189            return hasChanges;
1190        }
1191        
1192        return synchronize ? ExternalizableMetadataHelper.removeExternalMetadataIfExists(metadataHolder, metadataName)
1193                           : ExternalizableMetadataHelper.removeLocalMetadataIfExists(metadataHolder, metadataName);
1194    }
1195    
1196    /**
1197     * Handle metadata values
1198     * @param doc the document
1199     * @param content the content
1200     * @param metadataDef the metadata definition
1201     * @param untypedValues the metadata values, untyped
1202     * @param lang the language
1203     * @param catalog the catalog
1204     * @param logger the logger
1205     * @return the list of handle value, typed
1206     */
1207    protected List<Object> _handleMetadataValues(Document doc, ModifiableDefaultContent content, MetadataDefinition metadataDef, List<String> untypedValues, String lang, String catalog, Logger logger)
1208    {
1209        if (untypedValues == null)
1210        {
1211            return null;
1212        }
1213        
1214        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
1215        List<Object> typedValues = new ArrayList<>();
1216        
1217        for (String metadataValue : untypedValues)
1218        {
1219            switch (metadataDef.getType())
1220            {
1221                case DATE:
1222                    try
1223                    {
1224                        typedValues.add(dateFormat.parse(metadataValue));
1225                    }
1226                    catch (ParseException e)
1227                    {
1228                        logger.warn("Unable to parse the metadata '{}' of the content '{}' with the value '{}', it should respect the following format '{}'.", metadataDef.getName(), content.getId(), metadataValue, dateFormat);
1229                    }
1230                    break;
1231                case BOOLEAN:
1232                    typedValues.add(Boolean.valueOf(metadataValue));
1233                    break;
1234                case CONTENT:
1235                    Object value = _handleMetadataValuesAsContent(doc, metadataDef, metadataValue, lang, catalog, logger);
1236                    if (value != null)
1237                    {
1238                        typedValues.add(value);
1239                    }
1240                    break;
1241                case LONG:
1242                    typedValues.add(Long.valueOf(metadataValue));
1243                    break;
1244                case DOUBLE:
1245                    typedValues.add(Double.valueOf(metadataValue));
1246                    break;
1247                default:
1248                    if (content instanceof CourseList && metadataDef.getName().equals(CourseList.CHOICE_TYPE))
1249                    {
1250                        typedValues.add(metadataValue.toUpperCase());
1251                    }
1252                    else
1253                    {
1254                        typedValues.add(metadataValue);
1255                    }
1256                    break;
1257            }
1258        }
1259        
1260        return typedValues;
1261    }
1262    
1263    private Object _handleMetadataValuesAsContent(Document doc, MetadataDefinition metadataDef, String metadataValue, String lang, String catalog, Logger logger)
1264    {
1265        String refContentTypeId = metadataDef.getContentType();
1266        if (refContentTypeId != null)
1267        {
1268            ContentType refContentType = _contentTypeEP.getExtension(refContentTypeId);
1269            if (refContentTypeId.equals(PersonFactory.PERSON_CONTENT_TYPE))
1270            {
1271                Node personNode = _xPathProcessor.selectSingleNode(doc.getFirstChild(), "person[@CDMid ='" + metadataValue + "']");
1272                if (personNode != null)
1273                {
1274                    ModifiableDefaultContent person = _importOrSynchronizeContent(doc, personNode, getPersonWfDescription(), metadataValue, lang, null, metadataValue, logger);
1275                    if (person != null)
1276                    {
1277                        return person;
1278                    }
1279                }
1280                else
1281                {
1282                    logger.warn("There is no person tag corresponding to the CDM ID '{}'.", metadataValue);
1283                }
1284            }
1285            else if (refContentTypeId.equals(OrgUnitFactory.ORGUNIT_CONTENT_TYPE))
1286            {
1287                Node ouNode = _xPathProcessor.selectSingleNode(doc.getFirstChild(), "orgunit[@CDMid ='" + metadataValue + "']");
1288                if (ouNode != null)
1289                {
1290                    ModifiableDefaultContent orgUnit = _importOrSynchronizeContent(doc, ouNode, getOrgUnitWfDescription(), metadataValue, lang, null, metadataValue, logger);
1291                    if (orgUnit != null)
1292                    {
1293                        return orgUnit;
1294                    }
1295                }
1296                else
1297                {
1298                    logger.warn("There is no orgunit tag corresponding to the CDM ID '{}'.", metadataValue);
1299                }
1300            }
1301            else if (metadataDef.getName().equals("courseHolder"))
1302            {
1303                List<Expression> expList = getExpressionsList(lang, metadataValue, getCourseWfDescription().getContentType(), catalog);
1304                AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
1305                String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
1306                AmetysObjectIterable<CoursePart> contents = _resolver.query(xPathQuery);
1307                if (contents.getSize() > 0)
1308                {
1309                    return contents.iterator().next();
1310                }
1311                else
1312                {
1313                    logger.warn("There is no course part corresponding to the CDM ID '{}'.", metadataValue);
1314                }
1315            }
1316            else if (refContentType.isReferenceTable() && _odfRefTableHelper.isTableReference(refContentTypeId))
1317            {
1318                String entryId = _getIdFromCDMThenCode(refContentTypeId, metadataValue);
1319                if (StringUtils.isNotEmpty(entryId))
1320                {
1321                    return _resolver.resolveById(entryId);
1322                }
1323                else
1324                {
1325                    logger.warn("There is no entry corresponding to the CDM-fr or Ametys code '{}' in the reference table '{}'.", metadataValue, refContentTypeId);
1326                }
1327            }
1328        }
1329        else
1330        {
1331            logger.warn("Cannot match data '{}' of content type '{}' because it is not typed.", metadataDef.getName(), metadataDef.getReferenceContentType());
1332        }
1333        
1334        return null;
1335    }
1336
1337    /**
1338     * Search for translated contents
1339     * @param importedContent The imported content
1340     * @param contentType The content type
1341     */
1342    protected void _linkTranslationsIfExist(ModifiableContent importedContent, String contentType)
1343    {
1344        if (importedContent instanceof ProgramItem)
1345        {
1346            Expression expression = _getTranslationExpression(importedContent, contentType);
1347            String xPathQuery = ContentQueryHelper.getContentXPathQuery(expression);
1348    
1349            AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(xPathQuery);
1350            
1351            Map<String, String> translations = new HashMap<>();
1352            for (ModifiableDefaultContent content : contents)
1353            {
1354                translations.put(content.getLanguage(), content.getId());
1355            }
1356            
1357            for (ModifiableDefaultContent content : contents)
1358            {
1359                TranslationHelper.setTranslations(content, translations);
1360    
1361                Map<String, Object> eventParams = new HashMap<>();
1362                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1363                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
1364                _observationManager.notify(new Event(OdfObservationConstants.ODF_CONTENT_TRANSLATED, _currentUserProvider.getUser(), eventParams));
1365            }
1366        }
1367    }
1368    
1369    private Expression _getTranslationExpression(ModifiableContent content, String contentType)
1370    {
1371        List<Expression> expList = new ArrayList<>();
1372        
1373        if (StringUtils.isNotBlank(contentType))
1374        {
1375            expList.add(new ContentTypeExpression(Operator.EQ, contentType));
1376        }
1377        
1378        String catalog = content.getValue(ProgramItem.CATALOG);
1379        if (StringUtils.isNotBlank(catalog))
1380        {
1381            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
1382        }
1383        
1384        List<Expression> codeExpressionList = new ArrayList<>();
1385        String syncValue = content.getMetadataHolder().getString(getIdField());
1386        if (StringUtils.isNotBlank(syncValue))
1387        {
1388            codeExpressionList.add(new StringExpression(getIdField(), Operator.EQ, syncValue));
1389        }
1390
1391        String code = content.getValue(ProgramPart.CODE);
1392        if (StringUtils.isNotBlank(syncValue))
1393        {
1394            codeExpressionList.add(new StringExpression(ProgramPart.CODE, Operator.EQ, code));
1395        }
1396        
1397        if (!codeExpressionList.isEmpty())
1398        {
1399            expList.add(new OrExpression(codeExpressionList.toArray(Expression[]::new)));
1400        }
1401        
1402        return new AndExpression(expList.toArray(Expression[]::new));
1403    }
1404
1405    /**
1406     * Save the changes of the content.
1407     * @param content Content to save
1408     * @param contentTypeId The content type (for logs)
1409     * @param hasChanges If there are changes to save
1410     * @param logger The logger
1411     */
1412    protected void _saveContentChanges(ModifiableDefaultContent content, String contentTypeId, boolean hasChanges, Logger logger)
1413    {
1414        if (hasChanges)
1415        {
1416            content.saveChanges();
1417            _synchronizedContents.add(content.getId());
1418            if (!_importedContents.containsKey(content.getId()))
1419            {
1420                _nbSynchronizedContents++;
1421            }
1422        }
1423        else
1424        {
1425            logger.info("No changes detected for content '{}' with the content type '{}'", content.getTitle(), contentTypeId);
1426            if (!_importedContents.containsKey(content.getId()))
1427            {
1428                _nbNotChangedContents++;
1429            }
1430        }
1431    }
1432    
1433    /**
1434     * Set relations for the content.
1435     * @param content The content to update
1436     * @param children Children to set
1437     * @param courseParts {@link List} of {@link CoursePart} to set for {@link Course} content
1438     * @param removeOldCourseParts If the course parts doesn't have a code, we remove all the old {@link CoursePart}s before adding the new ones
1439     * @param logger The logger
1440     * @return <code>true</code> if changes occurs
1441     */
1442    protected boolean _setRelations(ModifiableDefaultContent content, List<ModifiableDefaultContent> children, List<ModifiableDefaultContent> courseParts, boolean removeOldCourseParts, Logger logger)
1443    {
1444        boolean hasChanges = false;
1445        
1446        if (!courseParts.isEmpty() && content instanceof Course)
1447        {
1448            if (removeOldCourseParts)
1449            {
1450                hasChanges = ExternalizableMetadataHelper.removeMetadataIfExists(content.getMetadataHolder(), Course.CHILD_COURSE_PARTS) || hasChanges;
1451            }
1452            hasChanges = _updateDoubleRelation(content, courseParts, Course.CHILD_COURSE_PARTS, CoursePart.PARENT_COURSES, logger) || hasChanges;
1453        }
1454        
1455        if (!children.isEmpty())
1456        {
1457            hasChanges = _setChildren(content, children, logger) || hasChanges;
1458        }
1459        
1460        // Set the orgUnit parent (if no parent is set)
1461        if (content instanceof OrgUnit)
1462        {
1463            hasChanges = _setOrgUnitParent(content, logger) || hasChanges;
1464        }
1465        
1466        return hasChanges;
1467    }
1468    
1469    /**
1470     * Set children for the given content.
1471     * @param content Content to add the children
1472     * @param children Children to add
1473     * @param logger The logger
1474     * @return <code>true</code> if changes occurs
1475     */
1476    protected boolean _setChildren(ModifiableDefaultContent content, List<ModifiableDefaultContent> children, Logger logger)
1477    {
1478        boolean hasChanges = false;
1479        
1480        String metadataName = null;
1481        String invertMetadataName = null;
1482        if (content instanceof CourseList)
1483        {
1484            metadataName = CourseList.CHILD_COURSES;
1485            invertMetadataName = Course.PARENT_COURSE_LISTS;
1486        }
1487        else if (content instanceof TraversableProgramPart)
1488        {
1489            metadataName = TraversableProgramPart.CHILD_PROGRAM_PARTS;
1490            invertMetadataName = ProgramPart.PARENT_PROGRAM_PARTS;
1491        }
1492        else if (content instanceof Course)
1493        {
1494            metadataName = Course.CHILD_COURSE_LISTS;
1495            invertMetadataName = CourseList.PARENT_COURSES;
1496        }
1497        
1498        if (metadataName != null)
1499        {
1500            hasChanges = _updateDoubleRelation(content, children, metadataName, invertMetadataName, logger);
1501        }
1502        
1503        return hasChanges;
1504    }
1505    
1506    /**
1507     * Set the orgUnit parent to rootOrgUnit.
1508     * @param orgUnit The orgunit to link
1509     * @param logger The logger
1510     * @return <code>true</code> if changes occurs
1511     */
1512    protected boolean _setOrgUnitParent(ModifiableDefaultContent orgUnit, Logger logger)
1513    {
1514        boolean hasChanges = false;
1515        
1516        // Set the orgUnit parent (if no parent is set)
1517        ModifiableCompositeMetadata holder = orgUnit.getMetadataHolder();
1518        if (!holder.hasMetadata(OrgUnit.PARENT_ORGUNIT))
1519        {
1520            OrgUnit rootOrgUnit = _rootOUProvider.getRoot();
1521            
1522            hasChanges = ExternalizableMetadataHelper.setMetadata(holder, OrgUnit.PARENT_ORGUNIT, rootOrgUnit) || hasChanges;
1523            hasChanges = _synchroComponent.updateRelation(rootOrgUnit.getMetadataHolder(), OrgUnit.CHILD_ORGUNITS, orgUnit, false) || hasChanges;
1524
1525            try
1526            {
1527                _synchroComponent.applyChanges(rootOrgUnit, 22, ObservationConstants.EVENT_CONTENT_MODIFIED, ignoreRights(), logger);
1528            }
1529            catch (RepositoryException e)
1530            {
1531                logger.error("An error occured during updating root org unit after synchronizing the content '{}'.", orgUnit.getId(), logger);
1532            }
1533        }
1534        
1535        return hasChanges;
1536    }
1537
1538    /**
1539     * Get the content ID from the CDM code, if there is no match with the CDM code, then we search with the code.
1540     * If nothing is found we return null. 
1541     * @param tableRefId The reference table ID
1542     * @param cdmCode The CDM code
1543     * @return A content ID or <code>null</code>
1544     */
1545    protected String _getIdFromCDMThenCode(String tableRefId, String cdmCode)
1546    {
1547        OdfReferenceTableEntry entry = _odfRefTableHelper.getItemFromCDM(tableRefId, cdmCode);
1548        if (entry == null)
1549        {
1550            entry = _odfRefTableHelper.getItemFromCode(tableRefId, cdmCode);
1551        }
1552        return entry != null ? entry.getId() : null;
1553    }
1554    
1555    private boolean _updateDoubleRelation(ModifiableDefaultContent content, List<ModifiableDefaultContent> children, String metadataName, String invertMetadataName, Logger logger)
1556    {
1557        boolean hasChanges = false;
1558        ModifiableCompositeMetadata holder = content.getMetadataHolder();
1559        
1560        // "Normal" relation
1561        if (removalSync() || !holder.hasMetadata(metadataName))
1562        {
1563            hasChanges = ExternalizableMetadataHelper.setMetadata(holder, metadataName, children.toArray(new ModifiableDefaultContent[children.size()]));
1564        }
1565        else
1566        {
1567            List<String> oldValues = new ArrayList<>(Arrays.asList(holder.getStringArray(metadataName)));
1568            
1569            for (ModifiableDefaultContent child : children)
1570            {
1571                hasChanges = _synchroComponent.updateRelation(holder, metadataName, child, false) || hasChanges;
1572                oldValues.remove(child.getId());
1573            }
1574            
1575            if (logger.isWarnEnabled())
1576            {
1577                for (String oldValue : oldValues)
1578                {
1579                    // Warn the old contents
1580                    ModifiableDefaultContent child = _resolver.resolveById(oldValue);
1581                    logger.warn("The ODF content '{}' ({}) is not linked anymore to the content '{}', it should be manually removed.", child.getTitle(), oldValue, content.getTitle());
1582                }
1583            }
1584        }
1585        
1586        // Invert relation
1587        for (ModifiableDefaultContent child : children)
1588        {
1589            hasChanges = _synchroComponent.updateRelation(child.getMetadataHolder(), invertMetadataName, content, false) || hasChanges;
1590        }
1591        
1592        return hasChanges;
1593    }
1594    
1595    private String _getXPathString(Node metadataNode, String xPath, String defaultValue)
1596    {
1597        String value = _xPathProcessor.evaluateAsString(metadataNode, xPath);
1598        if (StringUtils.isEmpty(value))
1599        {
1600            value = defaultValue;
1601        }
1602        return value;
1603    }
1604
1605    /**
1606     * If true, bypass the rights check during the import process
1607     * @return True if the rights check are bypassed during the import process
1608     */
1609    protected boolean ignoreRights()
1610    {
1611        return false;
1612    }
1613    
1614    /**
1615     * Get the program workflow description.
1616     * @return A {@link ContentWorkflowDescription} containing informations about the program workflow
1617     */
1618    protected ContentWorkflowDescription getProgramWfDescription()
1619    {
1620        return _PROGRAM_WF_DESCRIPTION;
1621    }
1622
1623    /**
1624     * Get the subprogram workflow description.
1625     * @return A {@link ContentWorkflowDescription} containing informations about the subprogram workflow
1626     */
1627    protected ContentWorkflowDescription getSubProgramWfDescription()
1628    {
1629        return _SUBPROGRAM_WF_DESCRIPTION;
1630    }
1631
1632    /**
1633     * Get the container workflow description.
1634     * @return A {@link ContentWorkflowDescription} containing informations about the container workflow
1635     */
1636    protected ContentWorkflowDescription getContainerWfDescription()
1637    {
1638        return _CONTAINER_WF_DESCRIPTION;
1639    }
1640
1641    /**
1642     * Get the course list workflow description.
1643     * @return A {@link ContentWorkflowDescription} containing informations about the course list workflow
1644     */
1645    protected ContentWorkflowDescription getCourseListWfDescription()
1646    {
1647        return _COURSELIST_WF_DESCRIPTION;
1648    }
1649
1650    /**
1651     * Get the course workflow description.
1652     * @return A {@link ContentWorkflowDescription} containing informations about the course workflow
1653     */
1654    protected ContentWorkflowDescription getCourseWfDescription()
1655    {
1656        return _COURSE_WF_DESCRIPTION;
1657    }
1658
1659    /**
1660     * Get the course part workflow description.
1661     * @return A {@link ContentWorkflowDescription} containing informations about the course part workflow
1662     */
1663    protected ContentWorkflowDescription getCoursePartWfDescription()
1664    {
1665        return _COURSEPART_WF_DESCRIPTION;
1666    }
1667
1668    /**
1669     * Get the orgunit workflow description.
1670     * @return A {@link ContentWorkflowDescription} containing informations about the orgunit workflow
1671     */
1672    protected ContentWorkflowDescription getOrgUnitWfDescription()
1673    {
1674        return _ORGUNIT_WF_DESCRIPTION;
1675    }
1676
1677    /**
1678     * Get the person workflow description.
1679     * @return A {@link ContentWorkflowDescription} containing informations about the person workflow
1680     */
1681    protected ContentWorkflowDescription getPersonWfDescription()
1682    {
1683        return _PERSON_WF_DESCRIPTION;
1684    }
1685    
1686    /**
1687     * Internal object to describe content workflow elements.
1688     */
1689    protected static class ContentWorkflowDescription
1690    {
1691        private String _contentType;
1692        private String _workflowName;
1693        private int _initialActionId;
1694        private int _validationActionId;
1695        
1696        ContentWorkflowDescription(String contentType, String workflowName, int initialActionId, int validationActionId)
1697        {
1698            _contentType = contentType;
1699            _workflowName = workflowName;
1700            _initialActionId = initialActionId;
1701            _validationActionId = validationActionId;
1702        }
1703        
1704        /**
1705         * Get the content type.
1706         * @return the content type ID
1707         */
1708        public String getContentType()
1709        {
1710            return _contentType;
1711        }
1712        
1713        /**
1714         * Get the workflow name.
1715         * @return the workflow name
1716         */
1717        public String getWorkflowName()
1718        {
1719            return _workflowName;
1720        }
1721        
1722        /**
1723         * Get the initial action ID.
1724         * @return the initial action ID
1725         */
1726        public int getInitialActionId()
1727        {
1728            return _initialActionId;
1729        }
1730        
1731        /**
1732         * Get the validation action ID.
1733         * @return the validation action ID
1734         */
1735        public int getValidationActionId()
1736        {
1737            return _validationActionId;
1738        }
1739    }
1740    
1741    private static class DocbookPrefixResolver implements PrefixResolver
1742    {
1743        private Map<String, String> _ns = new HashMap<>();
1744        
1745        public DocbookPrefixResolver()
1746        {
1747            _ns.put("docbook", "http://docbook.org/ns/docbook");
1748        }
1749        
1750        @Override
1751        public String prefixToNamespace(String prefix)
1752        {
1753            return _ns.get(prefix);
1754        }
1755    }
1756    
1757    @Override
1758    public List<Expression> getExpressionsList(String lang, String idValue, String contentType, String catalog)
1759    {
1760        List<Expression> expList = new ArrayList<>();
1761        
1762        if (StringUtils.isNotBlank(contentType))
1763        {
1764            expList.add(new ContentTypeExpression(Operator.EQ, contentType));
1765        }
1766        
1767        if (StringUtils.isNotBlank(idValue))
1768        {
1769            expList.add(new StringExpression(getIdField(), Operator.EQ, idValue));
1770        }
1771        
1772        if (StringUtils.isNotBlank(catalog))
1773        {
1774            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
1775        }
1776        
1777        if (StringUtils.isNotBlank(lang))
1778        {
1779            expList.add(new LanguageExpression(Operator.EQ, lang));
1780        }
1781        
1782        return expList;
1783    }
1784
1785    @Override
1786    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
1787    {
1788        if (additionalParameters == null || !additionalParameters.containsKey("contentTypes"))
1789        {
1790            throw new IllegalArgumentException("Content types shouldn't be null.");
1791        }
1792
1793        @SuppressWarnings("unchecked")
1794        List<String> contentTypeIds = (List<String>) additionalParameters.get("contentTypes");
1795        Set<String> allSyncFields = new HashSet<>();
1796        
1797        for (String contentTypeId : contentTypeIds)
1798        {
1799            Set<String> syncFields = _syncFieldsByContentType.computeIfAbsent(contentTypeId, k -> new HashSet<>());
1800            allSyncFields.addAll(syncFields);
1801        }
1802        
1803        return allSyncFields;
1804    }
1805}