001/*
002 *  Copyright 2017 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.apogee.scc;
017
018import java.io.ByteArrayInputStream;
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.math.BigDecimal;
024import java.sql.Clob;
025import java.sql.SQLException;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.Date;
029import java.util.HashMap;
030import java.util.HashSet;
031import java.util.LinkedHashMap;
032import java.util.LinkedHashSet;
033import java.util.LinkedList;
034import java.util.List;
035import java.util.Map;
036import java.util.Optional;
037import java.util.Set;
038import java.util.stream.Stream;
039
040import javax.jcr.Node;
041import javax.jcr.Value;
042
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.Context;
047import org.apache.avalon.framework.context.ContextException;
048import org.apache.avalon.framework.context.Contextualizable;
049import org.apache.avalon.framework.service.ServiceException;
050import org.apache.avalon.framework.service.ServiceManager;
051import org.apache.cocoon.Constants;
052import org.apache.cocoon.components.ContextHelper;
053import org.apache.cocoon.environment.Request;
054import org.apache.commons.io.IOUtils;
055import org.apache.commons.lang.StringUtils;
056import org.slf4j.Logger;
057
058import org.ametys.cms.content.external.ExternalizableMetadataHelper;
059import org.ametys.cms.content.external.ExternalizableMetadataProvider.ExternalizableMetadataStatus;
060import org.ametys.cms.contenttype.ContentType;
061import org.ametys.cms.data.ContentValue;
062import org.ametys.cms.repository.DefaultContent;
063import org.ametys.cms.repository.ModifiableContent;
064import org.ametys.cms.repository.ModifiableDefaultContent;
065import org.ametys.core.util.JSONUtils;
066import org.ametys.core.util.LambdaUtils;
067import org.ametys.plugins.contentio.ContentImporterHelper;
068import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
069import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollection;
070import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollectionDAO;
071import org.ametys.plugins.odfsync.apogee.ApogeeDAO;
072import org.ametys.plugins.odfsync.apogee.scc.impl.OrgUnitSynchronizableContentsCollection;
073import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
074import org.ametys.plugins.repository.metadata.ModifiableRichText;
075import org.ametys.runtime.config.Config;
076import org.ametys.runtime.i18n.I18nizableText;
077import org.ametys.runtime.model.ModelItem;
078import org.ametys.runtime.model.type.ModelItemTypeConstants;
079
080import com.google.common.base.CharMatcher;
081import com.google.common.collect.ImmutableList;
082import com.google.common.collect.ImmutableMap;
083
084/**
085 * Abstract class for Apogee synchronization
086 */
087public abstract class AbstractApogeeSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection implements Contextualizable, ApogeeSynchronizableContentsCollection
088{
089    /**
090     * Request attribute name to store handle contents during import or synchronization.
091     */
092    public static final String HANDLE_CONTENTS = AbstractApogeeSynchronizableContentsCollection.class.getName() + "$handleContents";
093    
094    /** Name of parameter holding the data source id */
095    public static final String PARAM_DATASOURCE_ID = "datasourceId";
096    /** Name of parameter holding the administrative year */
097    public static final String PARAM_YEAR = "year";
098    /** Name of parameter holding the field ID column */
099    protected static final String __PARAM_ID_COLUMN = "idColumn";
100    /** Name of parameter holding the fields mapping */
101    protected static final String __PARAM_MAPPING = "mapping";
102    /** Name of parameter into mapping holding the synchronized property */
103    protected static final String __PARAM_MAPPING_SYNCHRO = "synchro";
104    /** Name of parameter into mapping holding the path of metadata */
105    protected static final String __PARAM_MAPPING_METADATA_REF = "metadata-ref";
106    /** Name of parameter into mapping holding the remote attribute */
107    protected static final String __PARAM_MAPPING_ATTRIBUTE = "attribute";
108    /** Name of parameter holding the criteria */
109    protected static final String __PARAM_CRITERIA = "criteria";
110    /** Name of parameter into criteria holding a criterion */
111    protected static final String __PARAM_CRITERIA_CRITERION = "criterion";
112    /** Name of parameter into criterion holding the id */
113    protected static final String __PARAM_CRITERIA_CRITERION_ID = "id";
114    /** Name of parameter into criterion holding the label */
115    protected static final String __PARAM_CRITERIA_CRITERION_LABEL = "label";
116    /** Name of parameter into criterion holding the type */
117    protected static final String __PARAM_CRITERIA_CRITERION_TYPE = "type";
118    /** Name of paramter holding columns */
119    protected static final String __PARAM_COLUMNS = "columns";
120    /** Name of paramter into columns holding column */
121    protected static final String __PARAM_COLUMNS_COLUMN = "column";
122    
123    /** Default language configured for ODF */
124    protected String _odfLang;
125
126    /** Name of the Apogée column which contains the ID */
127    protected String _idColumn;
128    
129    /** Mapping between metadata and columns */
130    protected Map<String, List<String>> _mapping;
131    
132    /** External fields */
133    protected Set<String> _extFields;
134    
135    /** Synchronized fields */
136    protected Set<String> _syncFields;
137    
138    /** Synchronized fields */
139    protected Set<String> _columns;
140    
141    /** Synchronized fields */
142    protected Set<ApogeeCriterion> _criteria;
143    
144    /** Context */
145    protected Context _context;
146    
147    /** The DAO for remote DB Apogee */
148    protected ApogeeDAO _apogeeDAO;
149    
150    /** The JSON utils */
151    protected JSONUtils _jsonUtils;
152    
153    /** SCC DAO */
154    protected SynchronizableContentsCollectionDAO _sccDAO;
155    
156    @Override
157    public void service(ServiceManager manager) throws ServiceException
158    {
159        super.service(manager);
160        _apogeeDAO = (ApogeeDAO) manager.lookup(ApogeeDAO.ROLE);
161        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
162        _sccDAO = (SynchronizableContentsCollectionDAO) manager.lookup(SynchronizableContentsCollectionDAO.ROLE);
163    }
164
165    @Override
166    public void contextualize(Context context) throws ContextException
167    {
168        _context = context;
169    }
170    
171    @Override
172    protected void configureDataSource(Configuration configuration) throws ConfigurationException
173    {
174        @SuppressWarnings("resource")
175        InputStream is = null;
176        _odfLang = Config.getInstance().getValue("odf.programs.lang");
177        try
178        {
179            org.apache.cocoon.environment.Context ctx = (org.apache.cocoon.environment.Context) _context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
180            File apogeeMapping = new File(ctx.getRealPath("/WEB-INF/param/odf/apogee-mapping.xml"));
181            if (!apogeeMapping.isFile())
182            {
183                is = getClass().getResourceAsStream("/org/ametys/plugins/odfsync/apogee/apogee-mapping.xml");
184            }
185            else
186            {
187                is = new FileInputStream(apogeeMapping);
188            }
189            Configuration cfg = new DefaultConfigurationBuilder().build(is);
190            Configuration child = cfg.getChild(getMappingName());
191            if (child != null)
192            {
193                _criteria = new LinkedHashSet<>();
194                _columns = new LinkedHashSet<>();
195                _idColumn = child.getChild(__PARAM_ID_COLUMN).getValue();
196                _mapping = new HashMap<>();
197                _extFields = new HashSet<>();
198                _syncFields = new HashSet<>();
199                String mappingAsString = child.getChild(__PARAM_MAPPING).getValue();
200                _mapping.put(getIdField(), ImmutableList.of(getIdColumn()));
201                if (StringUtils.isNotEmpty(mappingAsString))
202                {
203                    List<Object> mappingAsList = _jsonUtils.convertJsonToList(mappingAsString);
204                    for (Object object : mappingAsList)
205                    {
206                        @SuppressWarnings("unchecked")
207                        Map<String, Object> field = (Map<String, Object>) object;
208                        
209                        String metadataRef = (String) field.get(__PARAM_MAPPING_METADATA_REF);
210                        
211                        String[] attributes = ((String) field.get(__PARAM_MAPPING_ATTRIBUTE)).split(",");
212                        _mapping.put(metadataRef, Arrays.asList(attributes));
213    
214                        boolean isSynchronized = field.containsKey(__PARAM_MAPPING_SYNCHRO) ? (Boolean) field.get(__PARAM_MAPPING_SYNCHRO) : false;
215                        if (isSynchronized)
216                        {
217                            _syncFields.add(metadataRef);
218                        }
219                        else
220                        {
221                            _extFields.add(metadataRef);
222                        }
223                    }
224                }
225
226                Configuration[] criteria = child.getChild(__PARAM_CRITERIA).getChildren(__PARAM_CRITERIA_CRITERION);
227                for (Configuration criterion : criteria)
228                {
229                    String id = criterion.getChild(__PARAM_CRITERIA_CRITERION_ID).getValue();
230                    I18nizableText label = _getCriterionLabel(criterion.getChild(__PARAM_CRITERIA_CRITERION_LABEL), id);
231                    String type = criterion.getChild(__PARAM_CRITERIA_CRITERION_TYPE).getValue("STRING");
232                    
233                    _criteria.add(new ApogeeCriterion(id, label, type));
234                }
235
236                Configuration[] columns = child.getChild(__PARAM_COLUMNS).getChildren(__PARAM_COLUMNS_COLUMN);
237                for (Configuration column : columns)
238                {
239                    _columns.add(column.getValue());
240                }
241            }
242        }
243        catch (Exception e)
244        {
245            throw new ConfigurationException("Error while parsing apogee-mapping.xml", e);
246        }
247        finally
248        {
249            IOUtils.closeQuietly(is);
250        }
251    }
252    
253    private I18nizableText _getCriterionLabel(Configuration configuration, String defaultValue)
254    {
255        if (configuration.getAttributeAsBoolean("i18n", false))
256        {
257            return new I18nizableText("plugin.odf-sync", configuration.getValue(defaultValue));
258        }
259        else
260        {
261            return new I18nizableText(configuration.getValue(defaultValue));
262        }
263    }
264    
265    @Override
266    public List<ModifiableDefaultContent> populate(Logger logger)
267    {
268        boolean isRequestAttributeOwner = false;
269        
270        Request request = ContextHelper.getRequest(_context);
271        if (request.getAttribute(HANDLE_CONTENTS) == null)
272        {
273            request.setAttribute(HANDLE_CONTENTS, new HashSet<String>());
274            isRequestAttributeOwner = true;
275        }
276        
277        List<ModifiableDefaultContent> populatedContents = super.populate(logger);
278        
279        if (isRequestAttributeOwner)
280        {
281            request.removeAttribute(HANDLE_CONTENTS);
282        }
283
284        return populatedContents;
285    }
286    
287    @Override
288    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
289    {
290        Map<String, Object> searchParams = new HashMap<>(parameters);
291        if (offset > 0)
292        {
293            searchParams.put("__offset", offset);
294        }
295        if (limit < Integer.MAX_VALUE)
296        {
297            searchParams.put("__limit", offset + limit);
298        }
299        searchParams.put("__order", _getSort(sort));
300
301        // We don't use session.selectMap which reorder data
302        List<Map<String, Object>> requestValues = _search(searchParams, logger);
303
304        String idColumn = getIdColumn();
305        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
306        for (Map<String, Object> contentValues : requestValues)
307        {
308            results.put(contentValues.get(idColumn).toString(), contentValues);
309        }
310        
311        for (Map<String, Object> result : results.values())
312        {
313            result.put(SCC_UNIQUE_ID, result.get(getIdColumn()));
314        }
315        
316        return results;
317    }
318    
319    @Override
320    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
321    {
322        Map<String, Map<String, List<Object>>> remoteValues = new HashMap<>();
323        
324        Map<String, Map<String, Object>> results = internalSearch(parameters, 0, Integer.MAX_VALUE, null, logger);
325        
326        if (results != null)
327        {
328            remoteValues = _sccHelper.organizeRemoteValuesByMetadata(results, _mapping);
329        }
330        
331        return remoteValues;
332    }
333    
334    @Override
335    protected List<ModifiableDefaultContent> _importOrSynchronizeContent(String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
336    {
337        return _importOrSynchronizeContent(idValue, _odfLang, remoteValues, forceImport, logger);
338    }
339
340    @Override
341    public List<ModifiableDefaultContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
342    {
343        boolean isRequestAttributeOwner = false;
344        
345        Request request = ContextHelper.getRequest(_context);
346        if (request.getAttribute(HANDLE_CONTENTS) == null)
347        {
348            request.setAttribute(HANDLE_CONTENTS, new HashSet<String>());
349            isRequestAttributeOwner = true;
350        }
351
352        List<ModifiableDefaultContent> createdContents = new ArrayList<>();
353        
354        Map<String, Object> parameters = putIdParameter(idValue);
355        Map<String, Map<String, List<Object>>> results = getTransformedRemoteValues(parameters, logger);
356        if (!results.isEmpty())
357        {
358            try
359            {
360                createdContents.add(_importContent(idValue, importParams, _odfLang, results.get(idValue), logger));
361            }
362            catch (Exception e)
363            {
364                _nbError++;
365                logger.error("An error occurred while importing or synchronizing content", e);
366            }
367        }
368        
369        if (isRequestAttributeOwner)
370        {
371            request.removeAttribute(HANDLE_CONTENTS);
372        }
373
374        return createdContents;
375    }
376    
377    @Override
378    public void synchronizeContent(ModifiableDefaultContent content, Logger logger) throws Exception
379    {
380        boolean isRequestAttributeOwner = false;
381        
382        Request request = ContextHelper.getRequest(_context);
383        if (request.getAttribute(HANDLE_CONTENTS) == null)
384        {
385            request.setAttribute(HANDLE_CONTENTS, new HashSet<String>());
386            isRequestAttributeOwner = true;
387        }
388        
389        super.synchronizeContent(content, logger);
390        
391        if (isRequestAttributeOwner)
392        {
393            request.removeAttribute(HANDLE_CONTENTS);
394        }
395    }
396    
397    @Override
398    protected Map<String, Object> putIdParameter(String idValue)
399    {
400        Map<String, Object> parameters = new HashMap<>();
401        parameters.put(getIdField(), idValue);
402        return parameters;
403    }
404    
405    /**
406     * Search the contents with the search parameters. Use id parameter to search an unique content.
407     * @param searchParams Search parameters
408     * @param logger The logger
409     * @return A Map of mapped metadatas extract from Apogée database ordered by content unique Apogée ID
410     */
411    protected abstract List<Map<String, Object>> _search(Map<String, Object> searchParams, Logger logger);
412    
413    /**
414     * Convert the {@link BigDecimal} values retrieved from database into long values
415     * @param searchResults The initial search results from database
416     * @return The converted search results
417     */
418    protected List<Map<String, Object>> _convertBigDecimal(List<Map<String, Object>> searchResults)
419    {
420        List<Map<String, Object>> convertedSearchResults = new ArrayList<>();
421        
422        for (Map<String, Object> searchResult : searchResults)
423        {
424            for (String key : searchResult.keySet())
425            {
426                searchResult.put(key, _convertBigDecimal(getContentType(), key, searchResult.get(key))); 
427            }
428            
429            convertedSearchResults.add(searchResult);
430        }
431        
432        return convertedSearchResults;
433    }
434    
435    /**
436     * Convert the object in parameter to a long if it's a {@link BigDecimal}, otherwise return the object itself.
437     * @param contentTypeId The content type of the parent content
438     * @param attributeName The metadata name
439     * @param objectToConvert The object to convert if necessary
440     * @return The converted object
441     */
442    protected Object _convertBigDecimal(String contentTypeId, String attributeName, Object objectToConvert)
443    {
444        if (objectToConvert instanceof BigDecimal)
445        {
446            ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
447            if (contentType.hasModelItem(attributeName))
448            {
449                ModelItem definition = contentType.getModelItem(attributeName);    
450                String typeId = definition.getType().getId();
451                switch (typeId)
452                {
453                    case ModelItemTypeConstants.DOUBLE_TYPE_ID:
454                        return ((BigDecimal) objectToConvert).doubleValue();
455                    case ModelItemTypeConstants.LONG_TYPE_ID:
456                        return ((BigDecimal) objectToConvert).longValue();
457                    default:
458                        // Do nothing
459                        break;
460                }
461            }
462            return ((BigDecimal) objectToConvert).toString();
463        }
464        return objectToConvert;
465    }
466    
467    /**
468     * Transform CLOB value to String value.
469     * @param value The input value
470     * @param idValue The identifier of the program
471     * @param logger The logger
472     * @return the same value, with CLOB transformed to String.
473     */
474    protected Object _transformClobToString(Object value, String idValue, Logger logger)
475    {
476        if (value instanceof Clob)
477        {
478            Clob clob = (Clob) value;
479            try
480            {
481                String strValue = IOUtils.toString(clob.getCharacterStream());
482                return CharMatcher.javaIsoControl().and(CharMatcher.anyOf("\r\n\t").negate()).removeFrom(strValue);
483            }
484            catch (SQLException | IOException e)
485            {
486                logger.error("Unable to get education add elements from the program '{}'.", idValue, e);
487                return null;
488            }
489            finally
490            {
491                try
492                {
493                    clob.free();
494                }
495                catch (SQLException e)
496                {
497                    // Ignore the exception.
498                }
499            }
500        }
501        
502        return value;
503    }
504    
505    /**
506     * Get the name of the mapping.
507     * @return the mapping name
508     */
509    protected abstract String getMappingName();
510    
511    /**
512     * Get the id of data source
513     * @return The id of data source
514     */
515    protected String getDataSourceId()
516    {
517        return (String) getParameterValues().get(PARAM_DATASOURCE_ID);
518    }
519    
520    /**
521     * Get the administrative year
522     * @return The administrative year
523     */
524    protected String getYear()
525    {
526        return (String) getParameterValues().get(PARAM_YEAR);
527    }
528    
529    /**
530     * Get the identifier column (can be a concatened column).
531     * @return the column id
532     */
533    protected String getIdColumn()
534    {
535        return _idColumn;
536    }
537    
538    @Override
539    public String getIdField()
540    {
541        return "apogeeSyncCode";
542    }
543
544    @Override
545    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
546    {
547        return _syncFields;
548    }
549
550    @Override
551    public Set<String> getExternalOnlyFields(Map<String, Object> additionalParameters)
552    {
553        return _extFields;
554    }
555    
556    @Override
557    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableDefaultContent content, boolean create, Logger logger)
558    {
559        ModifiableCompositeMetadata holder = content.getMetadataHolder();
560        
561        boolean hasChanges = _fillRichTexts(remoteValues, content, create, logger);
562        hasChanges = super._fillContent(remoteValues, content, create, logger) || hasChanges;
563        hasChanges = _handleAdditionalMetadata(holder, create) || hasChanges;
564        
565        if (!holder.hasMetadata(getIdField()))
566        {
567            holder.setMetadata(getIdField(), remoteValues.get(getIdField()).get(0).toString());
568            hasChanges |= create;
569        }
570        
571        return hasChanges;
572    }
573    
574    /**
575     * Fill the richt texts of the content with the remote values.
576     * @param remoteValues The remote values
577     * @param content The content to synchronize
578     * @param create <code>true</code> if content is creating, false if it is updated
579     * @param logger The logger
580     * @return <code>true</code> if changes were made
581     */
582    protected boolean _fillRichTexts(Map<String, List<Object>> remoteValues, ModifiableDefaultContent content, boolean create, Logger logger)
583    {
584        boolean hasChanges = false;
585
586        ModifiableCompositeMetadata holder = content.getMetadataHolder();
587        Map<String, Object> params = Map.of("contentTypes", List.of(getContentType()));
588        
589        for (String metadataName : getRichTextFields())
590        {
591            if (remoteValues.containsKey(metadataName))
592            {
593                boolean synchronize = getLocalAndExternalFields(params).contains(metadataName);
594                
595                ModifiableRichText richText = ExternalizableMetadataHelper.getRichText(holder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true);
596                richText.setMimeType("text/xml");
597                richText.setLastModified(new Date());
598                try
599                {
600                    _setRichTextValue(richText, remoteValues.get(metadataName));
601                }
602                catch (IOException e)
603                {
604                    logger.error("An error occured while parsing the rich text '{}' of the content '{}'", metadataName, content.getTitle(), e);
605                }
606                
607                if (synchronize && create)
608                {
609                    // Force external status
610                    ExternalizableMetadataHelper.updateStatus(holder,  metadataName, ExternalizableMetadataStatus.EXTERNAL);
611                }
612                
613                remoteValues.remove(metadataName);
614                hasChanges = true;
615            }
616        }
617        
618        return hasChanges;
619    }
620    
621    /**
622     * Set the value on the rich text.
623     * @param richText The rich text to update
624     * @param remoteValue The remote value of the rich text (can be composed of several texts to concatenate)
625     * @throws IOException if an exception occurs
626     */
627    protected void _setRichTextValue(ModifiableRichText richText, List<Object> remoteValue) throws IOException
628    {
629        List<String> lines = new LinkedList<>();
630        for (Object remoteText : remoteValue)
631        {
632            lines.add(remoteText.toString());
633        }
634        
635        String docbook = ContentImporterHelper.textToDocbook(lines.toArray(new String[lines.size()]));
636        
637        try (ByteArrayInputStream is =  new ByteArrayInputStream(docbook.getBytes("UTF-8")))
638        {
639            richText.setInputStream(is);
640        }
641    }
642
643    /**
644     * Method to add additional metadata on import or synchronize.
645     * @param holder The holder of the content to update
646     * @param create If we are on creation mode
647     * @return <code>true</code> if changes has been made
648     */
649    protected boolean _handleAdditionalMetadata(ModifiableCompositeMetadata holder, boolean create)
650    {
651        // Do nothing
652        return false;
653    }
654
655    /**
656     * Get the list of rich text fields of the imported content.
657     * @return The list of the rich text fields metadata name
658     */
659    protected Set<String> getRichTextFields()
660    {
661        return new HashSet<>();
662    }
663    
664    @Override
665    protected void configureSearchModel()
666    {
667        for (ApogeeCriterion criterion : _criteria)
668        {
669            _searchModelConfiguration.addCriterion(criterion.getId(), criterion.getLabel(), criterion.getType());
670        }
671        for (String columnName : _columns)
672        {
673            _searchModelConfiguration.addColumn(columnName);
674        }
675    }
676    
677    @Override
678    protected boolean additionalCommonOperations(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Map<String, Object> importParams, boolean create, Logger logger)
679    {
680        boolean hasChanges = super.additionalCommonOperations(content, remoteValues, importParams, create, logger);
681
682        Object parentId = importParams != null && importParams.containsKey("parentId") ? importParams.get("parentId") : null;
683        ModifiableDefaultContent parentContent = parentId != null ? _resolver.resolveById(parentId.toString()) : null;
684        
685        hasChanges = handleParent(content, parentContent, logger) || hasChanges;
686        hasChanges = handleChildren(content, create, logger) || hasChanges;
687        hasChanges = setAdditionalMetadata(content, remoteValues, create, logger) || hasChanges;
688        
689        return hasChanges;
690    }
691    
692    /**
693     * Set the parent metadata and invert relation.
694     * @param currentContent Current content
695     * @param parentContent Parent content to set
696     * @param logger The logger
697     * @return <code>true</code> if there are changes
698     */
699    protected boolean handleParent(ModifiableDefaultContent currentContent, ModifiableDefaultContent parentContent, Logger logger)
700    {
701        // Nothing to do by default
702        return false;
703    }
704    
705    /**
706     * Set the children metadata and invert relation, import and synchronize the children too.
707     * @param content Current content
708     * @param create true if the content has been newly created
709     * @param logger The logger
710     * @return <code>true</code> if there are changes
711     */
712    protected boolean handleChildren(ModifiableDefaultContent content, boolean create, Logger logger)
713    {
714        // Nothing to do by default
715        return false;
716    }
717
718    /**
719     * Set the additional metadata.
720     * @param content Current content
721     * @param remoteValues Values of the content
722     * @param create true if the content has been newly created
723     * @param logger The logger
724     * @return <code>true</code> if there are changes
725     */
726    protected boolean setAdditionalMetadata(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, boolean create, Logger logger)
727    {
728        // Nothing to do by default
729        return false;
730    }
731    
732    /**
733     * Transform the given {@link List} of {@link Object} to a {@link String} representing the ordered fields for SQL.
734     * @param sortList The sort list object to transform to the list of ordered fields compatible with SQL.
735     * @return A string representing the list of ordered fields
736     */
737    @SuppressWarnings("unchecked")
738    protected String _getSort(List<Object> sortList)
739    {
740        if (sortList != null)
741        {
742            StringBuilder sort = new StringBuilder();
743            
744            for (Object sortValueObj : sortList)
745            {
746                Map<String, Object> sortValue = (Map<String, Object>) sortValueObj;
747                
748                sort.append(sortValue.get("property"));
749                if (sortValue.containsKey("direction"))
750                {
751                    sort.append(" ");
752                    sort.append(sortValue.get("direction"));
753                    sort.append(",");
754                }
755                else
756                {
757                    sort.append(" ASC,");
758                }
759            }
760            
761            sort.deleteCharAt(sort.length() - 1);
762            
763            return sort.toString();
764        }
765        
766        return null;
767    }
768    
769    @Override
770    public int getTotalCount(Map<String, Object> parameters, Logger logger)
771    {
772        // Remove empty parameters
773        Map<String, Object> searchParams = new HashMap<>();
774        for (String parameterName : parameters.keySet())
775        {
776            Object parameterValue = parameters.get(parameterName);
777            if (parameterValue != null && !parameterValue.toString().isEmpty())
778            {
779                searchParams.put(parameterName, parameterValue);
780            }
781        }
782        
783        searchParams.put("__count", true);
784        
785        List<Map<String, Object>> results = _search(searchParams, logger);
786        if (results != null && !results.isEmpty())
787        {
788            return Integer.valueOf(results.get(0).get("COUNT(*)").toString()).intValue();
789        }
790        
791        return 0;
792    }
793    
794    @Override
795    protected ModifiableDefaultContent _importContent(String idValue, Map<String, Object> importParams, String lang, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
796    {
797        ModifiableDefaultContent content = super._importContent(idValue, importParams, lang, _transformOrgUnitMetadata(remoteValues, logger), logger);
798        if (content != null)
799        {
800            addToHandleContents(content.getId());
801        }
802        return content;
803    }
804    
805    @Override
806    protected ModifiableDefaultContent _synchronizeContent(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
807    {
808        if (!addToHandleContents(content.getId()))
809        {
810            return content;
811        }
812        return super._synchronizeContent(content, _transformOrgUnitMetadata(remoteValues, logger), logger);
813    }
814    
815    @Override
816    protected List<ModifiableDefaultContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger)
817    {
818        List<ModifiableDefaultContent> contents = new ArrayList<>();
819        
820        Map<String, Map<String, List<Object>>> remoteValuesByContent = getTransformedRemoteValues(searchParams, logger);
821        for (String idValue : remoteValuesByContent.keySet())
822        {
823            Map<String, List<Object>> remoteValues = remoteValuesByContent.get(idValue);
824            try
825            {
826                ModifiableDefaultContent content = getContent(_odfLang, idValue);
827                // The content doesn't exist, so we import it ...
828                if (content == null)
829                {
830                    // ... only if forceImport is true or if we don't synchronize only existing contents
831                    if (forceImport || !synchronizeExistingContentsOnly())
832                    {
833                        _handleContent(idValue);
834                        contents.add(_importContent(idValue, null, _odfLang, remoteValues, logger));
835                    }
836                }
837                // The content already exists and doesn't have the search parameter "parentCode":
838                // it means it's not a child content we try to synchronize but synchronization is
839                // not recursive, so we can synchronize it
840                else if (!searchParams.containsKey("parentCode"))
841                {
842                    _handleContent(idValue);
843                    contents.add(_synchronizeContent(content, remoteValues, logger));
844                }
845                // ... else we don't synchronize the content and just add the content to the list sent to the caller
846                else
847                {
848                    contents.add(content);
849                }
850            }
851            catch (Exception e)
852            {
853                _nbError++;
854                logger.error("An error occurred while importing or synchronizing content", e);
855            }
856        }
857        
858        return contents;
859    }
860    
861    /**
862     * Import and synchronize children of the given content, then edit the structure of the content and its children.
863     * In the usual case, if we are in import mode (create to true) or if we trust the source (removalSync to true) we just synchronize structure but we don't synchronize child contents
864     * @param content Parent content
865     * @param sccModelId SCC model ID
866     * @param metadataName Metadata name to set
867     * @param invertMetadataName Metadata name of the invert relation
868     * @param create true if the content has been newly created
869     * @param logger The logger
870     * @return <code>true</code> if there are changes
871     */
872    protected boolean importOrSynchronizeChildren(ModifiableDefaultContent content, String sccModelId, String metadataName, String invertMetadataName, boolean create, Logger logger)
873    {
874        return (create || removalSync())
875            // Override children by the children imported or synchronized from the Apogee datasource
876            ? _resetChildren(content, sccModelId, metadataName, invertMetadataName, logger)
877            : false;
878    }
879    
880    /**
881     * Reset the children of the structure to force with the children from Apogée.
882     * @param content Parent content
883     * @param sccModelId SCC model ID
884     * @param metadataName Metadata name to set
885     * @param invertMetadataName Metadata name of the invert relation
886     * @param logger The logger
887     * @return <code>true</code> if there are changes
888     */
889    protected boolean _resetChildren(ModifiableDefaultContent content, String sccModelId , String metadataName, String invertMetadataName, Logger logger)
890    {
891        boolean hasChanges = false;
892
893        ModifiableCompositeMetadata cm = content.getMetadataHolder();
894        
895        // Remove all children relations
896        ContentValue[] oldChildren = content.getValue(metadataName, false, new ContentValue[0]);
897        if (oldChildren.length > 0)
898        {
899            hasChanges = ExternalizableMetadataHelper.removeMetadataIfExists(cm, metadataName) || hasChanges;
900            
901            for (ContentValue child : oldChildren)
902            {
903                Optional<ModifiableContent> childContent = child.getContentIfExists();
904                if (childContent.isPresent())
905                {
906                    hasChanges = _updateRelation(childContent.get().getMetadataHolder(), invertMetadataName, content, true) || hasChanges;
907                }
908            }
909        }
910        
911        // Get the SCC for children
912        SynchronizableContentsCollection scc = _sccHelper.getSCCFromModelId(sccModelId);
913
914        // If SCC exists, search for children
915        if (scc != null && scc instanceof ApogeeSynchronizableContentsCollection)
916        {
917            // Synchronize or import children content
918            String syncCode = cm.getString(getIdField());
919            List<ModifiableDefaultContent> importedOrSynchronizedChildren = ((ApogeeSynchronizableContentsCollection) scc).importOrSynchronizeContents(ImmutableMap.of("parentCode", syncCode), logger);
920            
921            // Update child relation to link to the parent
922            ModifiableDefaultContent[] newChildren = importedOrSynchronizedChildren.toArray(new ModifiableDefaultContent[importedOrSynchronizedChildren.size()]);
923            hasChanges = ExternalizableMetadataHelper.setMetadata(cm, metadataName, newChildren) || hasChanges;
924
925            // Update parent relation on each children
926            for (ModifiableDefaultContent child : newChildren)
927            {
928                hasChanges = _updateRelation(child.getMetadataHolder(), invertMetadataName, content) || hasChanges;
929            }
930        }
931        
932        return hasChanges;
933    }
934
935    /**
936     * Get the Apogee SCC for the content if it exists
937     * @param content The content to search on
938     * @param logger The logger
939     * @return the Apogee SCC
940     */
941    protected Optional<AbstractApogeeSynchronizableContentsCollection> _getContentSCC(DefaultContent content, Logger logger)
942    {
943        Node node = content.getNode();
944        try
945        {
946            if (node.hasProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY))
947            {
948                Value[] values = node.getProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY).getValues();
949                return Stream.of(values)
950                    .map(LambdaUtils.wrap(Value::getString))
951                    .map(_sccDAO::getSynchronizableContentsCollection)
952                    .filter(AbstractApogeeSynchronizableContentsCollection.class::isInstance)
953                    .map(AbstractApogeeSynchronizableContentsCollection.class::cast)
954                    .findFirst();
955            }
956        }
957        catch (Exception e)
958        {
959            logger.error("An error occurred getting ametys-internal:scc property", e);
960        }
961        
962        return Optional.empty();
963    }
964    
965    @Override
966    public List<ModifiableDefaultContent> importOrSynchronizeContents(Map<String, Object> searchParams, Logger logger)
967    {
968        return _importOrSynchronizeContents(searchParams, true, logger);
969    }
970    
971    /**
972     * Add the content ID to the handle contents list.
973     * @param contentId Content ID
974     * @return <code>true</code> if the content ID have been added, <code>false</code> is returned if the content ID already exists in the handle contents list.
975     */
976    protected boolean addToHandleContents(String contentId)
977    {
978        Request request = ContextHelper.getRequest(_context);
979        @SuppressWarnings("unchecked")
980        Set<String> handleContents = (Set<String>) request.getAttribute(HANDLE_CONTENTS);
981        boolean added = handleContents.add(contentId);
982        request.setAttribute(HANDLE_CONTENTS, handleContents);
983        return added;
984    }
985
986    @SuppressWarnings("unchecked")
987    private Map<String, List<Object>> _transformOrgUnitMetadata(Map<String, List<Object>> remoteValues, Logger logger)
988    {
989        // Transform orgUnit values and import content if necessary (useful for Course and SubProgram)
990        SynchronizableContentsCollection scc = _sccHelper.getSCCFromModelId(OrgUnitSynchronizableContentsCollection.MODEL_ID);
991        
992        List<Object> orgUnitCodes = remoteValues.get("orgUnit");
993        if (orgUnitCodes != null && !orgUnitCodes.isEmpty())
994        {
995            List<?> orgUnitContents = null;
996            String orgUnitCode = orgUnitCodes.get(0).toString();
997            
998            if (scc != null)
999            {
1000                try
1001                {
1002                    ModifiableDefaultContent orgUnitContent = scc.getContent(_odfLang, orgUnitCode);
1003                    if (orgUnitContent == null)
1004                    {
1005                        orgUnitContents = scc.importContent(orgUnitCode, null, logger);
1006                    }
1007                    else
1008                    {
1009                        orgUnitContents = ImmutableList.of(orgUnitContent);
1010                    }
1011                }
1012                catch (Exception e)
1013                {
1014                    logger.error("An error occured during the import of the OrgUnit identified by the synchronization code '{}'", orgUnitCode, e);
1015                }
1016            }
1017            
1018            if (orgUnitContents == null)
1019            {
1020                // Impossible link to orgUnit
1021                remoteValues.remove("orgUnit");
1022                logger.warn("Impossible to import the OrgUnit with the synchronization code '{}', check if you set the OrgUnit SCC with the following model ID: '{}'", orgUnitCode, OrgUnitSynchronizableContentsCollection.MODEL_ID);
1023            }
1024            else
1025            {
1026                remoteValues.put("orgUnit", (List<Object>) orgUnitContents);
1027            }
1028        }
1029        
1030        return remoteValues;
1031    }
1032
1033    @Override
1034    public boolean handleRightAssignmentContext()
1035    {
1036        // Rights on ODF contents are handled by ODFRightAssignmentContext
1037        return false;
1038    }
1039}