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