001/*
002 *  Copyright 2021 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 */
016
017package org.ametys.plugins.odfsync.pegase.scc;
018
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.LinkedHashMap;
025import java.util.LinkedHashSet;
026import java.util.List;
027import java.util.Map;
028import java.util.Optional;
029import java.util.Set;
030import java.util.UUID;
031import java.util.function.Predicate;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.configuration.Configuration;
036import org.apache.avalon.framework.configuration.ConfigurationException;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.commons.lang3.StringUtils;
040import org.slf4j.Logger;
041
042import org.ametys.cms.data.ContentSynchronizationResult;
043import org.ametys.cms.data.ContentValue;
044import org.ametys.cms.repository.Content;
045import org.ametys.cms.repository.ContentQueryHelper;
046import org.ametys.cms.repository.LanguageExpression;
047import org.ametys.cms.repository.ModifiableContent;
048import org.ametys.cms.repository.WorkflowAwareContent;
049import org.ametys.core.schedule.progression.ContainerProgressionTracker;
050import org.ametys.core.util.JSONUtils;
051import org.ametys.odf.ProgramItem;
052import org.ametys.odf.catalog.CatalogsManager;
053import org.ametys.odf.cdmfr.CDMFRHandler;
054import org.ametys.odf.course.Course;
055import org.ametys.odf.course.CourseFactory;
056import org.ametys.odf.courselist.CourseList;
057import org.ametys.odf.courselist.CourseListFactory;
058import org.ametys.odf.enumeration.OdfReferenceTableEntry;
059import org.ametys.odf.enumeration.OdfReferenceTableHelper;
060import org.ametys.odf.program.Container;
061import org.ametys.odf.program.ContainerFactory;
062import org.ametys.odf.program.ProgramFactory;
063import org.ametys.odf.program.ProgramPart;
064import org.ametys.odf.program.SubProgramFactory;
065import org.ametys.odf.program.TraversableProgramPart;
066import org.ametys.odf.workflow.AbstractCreateODFContentFunction;
067import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
068import org.ametys.plugins.odfsync.pegase.ws.PegaseApiManager;
069import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription;
070import org.ametys.plugins.repository.AmetysObjectIterable;
071import org.ametys.plugins.repository.jcr.NameHelper;
072import org.ametys.plugins.repository.query.expression.AndExpression;
073import org.ametys.plugins.repository.query.expression.Expression;
074import org.ametys.plugins.repository.query.expression.Expression.Operator;
075import org.ametys.plugins.repository.query.expression.StringExpression;
076import org.ametys.runtime.config.Config;
077import org.ametys.runtime.i18n.I18nizableText;
078import org.ametys.runtime.model.View;
079
080import com.opensymphony.workflow.WorkflowException;
081
082import fr.pcscol.pegase.odf.ApiException;
083import fr.pcscol.pegase.odf.api.MaquettesExterneApi;
084import fr.pcscol.pegase.odf.api.ObjetsMaquetteExterneApi;
085import fr.pcscol.pegase.odf.externe.model.DescripteursFormation;
086import fr.pcscol.pegase.odf.externe.model.DescripteursGroupement;
087import fr.pcscol.pegase.odf.externe.model.DescripteursObjetFormation;
088import fr.pcscol.pegase.odf.externe.model.DescripteursObjetFormationSyllabus;
089import fr.pcscol.pegase.odf.externe.model.DescripteursObjetMaquette;
090import fr.pcscol.pegase.odf.externe.model.DescripteursSise;
091import fr.pcscol.pegase.odf.externe.model.DescripteursSyllabus;
092import fr.pcscol.pegase.odf.externe.model.EnfantsStructure;
093import fr.pcscol.pegase.odf.externe.model.MaquetteStructure;
094import fr.pcscol.pegase.odf.externe.model.Nomenclature;
095import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteDetail;
096import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteStructure;
097import fr.pcscol.pegase.odf.externe.model.ObjetMaquetteSummary;
098import fr.pcscol.pegase.odf.externe.model.Pageable;
099import fr.pcscol.pegase.odf.externe.model.PagedObjetMaquetteSummaries;
100import fr.pcscol.pegase.odf.externe.model.PlageDeChoix;
101import fr.pcscol.pegase.odf.externe.model.TypeObjetMaquette;
102
103/**
104 * SCC for Pegase (COF).
105 */
106public class PegaseSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection
107{
108    private static final String __INSERTED_GROUPEMENT_SUFFIX = "-LIST-";
109    
110    /** The JSON utils */
111    protected JSONUtils _jsonUtils;
112    
113    /** The catalogs manager */
114    protected CatalogsManager _catalogsManager;
115    
116    /** The Pégase API manager */
117    protected PegaseApiManager _pegaseApiManager;
118    
119    /** The CDM-fr handler */
120    protected CDMFRHandler _cdmfrHandler;
121    
122    /** The PégaseSCC helper */
123    protected PegaseSCCMappingHelper _pegaseSccMappingHelper;
124    
125    /** The reference table helper */
126    protected OdfReferenceTableHelper _refTableHelper;
127    
128    /** Mapping between metadata and columns */
129    protected Map<String, Map<String, String>> _mappingByContentType;
130    
131    /** Search fields to display */
132    protected Set<String> _searchFields;
133
134    /** List of imported contents */
135    protected Map<String, Integer> _importedContents;
136    
137    /** List of synchronized contents having differences */
138    protected Set<String> _synchronizedContents;
139    
140    /** List of updated contents by relation */
141    protected Set<String> _updatedRelationContents;
142    
143    /** Map to link contents to its children at the end of the process */
144    protected Map<String, Set<String>> _contentsChildren;
145    
146    /** Default language configured for ODF */
147    protected String _odfLang;
148    
149    /** The structure code for Pégase */
150    protected String _structureCode;
151    
152    @Override
153    public void service(ServiceManager manager) throws ServiceException
154    {
155        super.service(manager);
156        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
157        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
158        _pegaseApiManager = (PegaseApiManager) manager.lookup(PegaseApiManager.ROLE);
159        _cdmfrHandler = (CDMFRHandler) manager.lookup(CDMFRHandler.ROLE);
160        _pegaseSccMappingHelper = (PegaseSCCMappingHelper) manager.lookup(PegaseSCCMappingHelper.ROLE);
161        _refTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
162    }
163    
164    @Override
165    public String getIdField()
166    {
167        return "pegaseSyncCode";
168    }
169
170    /**
171     * Get the identifier JSON field.
172     * @return the column id
173     */
174    protected String getIdColumn()
175    {
176        return "id";
177    }
178
179    @SuppressWarnings("unchecked")
180    @Override
181    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
182    {
183        return Optional.ofNullable(additionalParameters)
184            .map(params -> params.get("contentTypes"))
185            .filter(List.class::isInstance)
186            .map(cTypes -> (List<String>) cTypes)
187            .filter(Predicate.not(List::isEmpty))
188            .map(l -> l.get(0))
189            .map(_mappingByContentType::get)
190            .map(Map::keySet)
191            .orElse(Set.of());
192    }
193
194    @Override
195    protected Map<String, Object> putIdParameter(String idValue)
196    {
197        Map<String, Object> parameters = new HashMap<>();
198        parameters.put(getIdColumn(), List.of(idValue));
199        return parameters;
200    }
201    
202    @Override
203    protected void configureDataSource(Configuration configuration) throws ConfigurationException
204    {
205        _odfLang = Config.getInstance().getValue("odf.programs.lang");
206        _searchFields = new HashSet<>();
207        
208        // Mapping by content type Map<String(Type), Map<String(Ametys field), String(Pégase field)>>
209        _mappingByContentType = new HashMap<>();
210        
211        if (Config.getInstance().getValue("pegase.activate", true, false))
212        {
213            _structureCode = Config.getInstance().getValue("pegase.structure.code");
214    
215            // Champs affichés dans la recherche
216            _searchFields.add("libelle");
217            _searchFields.add("code");
218            _searchFields.add("espace");
219            _searchFields.add("id");
220            
221            // Program
222            Map<String, String> contentTypeMapping = new HashMap<>();
223            contentTypeMapping.put("title", "libelle");
224            contentTypeMapping.put("pegaseCode", "code");
225            contentTypeMapping.put("ects", "ects");
226            contentTypeMapping.put("educationKind", "codeNatureDiplomeCode");
227            contentTypeMapping.put("presentation", "description");
228            contentTypeMapping.put("objectives", "objectif");
229            contentTypeMapping.put("neededPrerequisite", "prerequisPedagogique");
230            contentTypeMapping.put("mention", "codeMention");
231            contentTypeMapping.put("domain", "codeDomaineFormation");
232            contentTypeMapping.put("programField", "codeChampFormation");
233            contentTypeMapping.put("educationLanguage", "langueEnseignement");
234            contentTypeMapping.put("educationLevel", "codeNiveauFormation");
235            contentTypeMapping.put("rncpLevel", "codeNiveauDiplome");
236            contentTypeMapping.put("degree", "codeTypeDiplome");
237            contentTypeMapping.put("orgUnit", "orgUnit");
238            contentTypeMapping.put("additionalInformations", "autresInformations");
239            _mappingByContentType.put(ProgramFactory.PROGRAM_CONTENT_TYPE, contentTypeMapping);
240            
241            // SubProgram
242            contentTypeMapping = new HashMap<>();
243            contentTypeMapping.put("title", "libelle");
244            contentTypeMapping.put("pegaseCode", "code");
245            contentTypeMapping.put("ects", "ects");
246            contentTypeMapping.put("presentation", "description");
247            contentTypeMapping.put("objectives", "objectif");
248            contentTypeMapping.put("neededPrerequisite", "prerequisPedagogique");
249            contentTypeMapping.put("orgUnit", "orgUnit");
250            contentTypeMapping.put("numberOfStudents", "capaciteAccueil");
251            contentTypeMapping.put("additionalInformations", "autresInformations");
252            
253            _mappingByContentType.put(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, contentTypeMapping);
254    
255            // Container
256            contentTypeMapping = new HashMap<>();
257            contentTypeMapping.put("title", "libelle");
258            contentTypeMapping.put("pegaseCode", "code");
259            contentTypeMapping.put("ects", "ects");
260            contentTypeMapping.put("nature", "typeObjetFormation");
261            contentTypeMapping.put("period", "typeObjetFormation");
262            contentTypeMapping.put("description", "description");
263            _mappingByContentType.put(ContainerFactory.CONTAINER_CONTENT_TYPE, contentTypeMapping);
264            
265            // CourseList
266            contentTypeMapping = new HashMap<>();
267            contentTypeMapping.put("title", "libelle");
268            contentTypeMapping.put("pegaseCode", "code");
269            contentTypeMapping.put("plageDeChoix", "plageDeChoix");
270            contentTypeMapping.put("obligatoire", "obligatoire");
271            contentTypeMapping.put("min", "plageMin");
272            contentTypeMapping.put("max", "plageMax");
273            _mappingByContentType.put(CourseListFactory.COURSE_LIST_CONTENT_TYPE, contentTypeMapping);
274    
275            // Course
276            contentTypeMapping = new HashMap<>();
277            contentTypeMapping.put("title", "libelle");
278            contentTypeMapping.put("pegaseCode", "code");
279            contentTypeMapping.put("ects", "ects");
280            contentTypeMapping.put("description", "description");
281            contentTypeMapping.put("courseType", "typeObjetFormation");
282            contentTypeMapping.put("objectives", "objectif");
283            contentTypeMapping.put("neededPrerequisite", "prerequisPedagogique");
284            contentTypeMapping.put("bibliography", "bibliographie");
285            contentTypeMapping.put("additionalInformations", "autresInformations");
286            contentTypeMapping.put("openToExchangeStudents", "ouvertureALaMobiliteEntrante");
287            contentTypeMapping.put("formOfAssessment", "modalitesEvaluation");
288            contentTypeMapping.put("teachingLanguage", "langueEnseignement");
289
290            _mappingByContentType.put(CourseFactory.COURSE_CONTENT_TYPE, contentTypeMapping);
291        }
292    }
293
294    @Override
295    protected void configureSearchModel()
296    {
297        List<String> sortableColumns = List.of("code", "libelle");
298
299        _searchModelConfiguration.addCriterion("libelle", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_LIBELLE"), "string");
300        _searchModelConfiguration.addCriterion("validee", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VALIDEE"), "boolean", "edition.boolean-combobox");
301
302        for (String keptField : _searchFields)
303        {
304            if (sortableColumns.contains(keptField))
305            {
306                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), true);
307            }
308            else
309            {
310                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), false);
311            }
312        }
313    }
314
315    /**
316     * Get the catalog for import.
317     * @return the catalog
318     */
319    protected String getCatalog()
320    {
321        return Optional.of(getParameterValues())
322                   .map(params -> params.get("catalog"))
323                   .map(String.class::cast)
324                   .filter(StringUtils::isNotBlank)
325                   .orElseGet(() -> _catalogsManager.getDefaultCatalogName());
326    }
327    
328    @Override
329    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
330    {
331        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
332        
333        try
334        {
335            ObjetsMaquetteExterneApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteExterneApi();
336            
337            // Get details for all requested programs by objetsMaquetteApi
338            PagedObjetMaquetteSummaries pagedPrograms = _getPagedProgramsSummaries(parameters, offset, limit, sort, objetsMaquetteApi);
339            Long total = pagedPrograms.getTotalElements();
340            
341            if (total != null)
342            {
343                // if total is provided, store it so that getTotalCount won't do a useless full search
344                parameters.put("totalCount", total.longValue());
345            }
346            
347            List<ObjetMaquetteSummary> programs = pagedPrograms.getItems();
348            if (programs != null)
349            {
350                for (ObjetMaquetteSummary programSummary : programs)
351                {
352                    String pegaseSyncCode = programSummary.getId().toString();
353                    
354                    // If not an import, fill the results with Pégase synchronization code
355                    Map<String, Object> result = results.computeIfAbsent(pegaseSyncCode, __ -> new HashMap<>());
356                    Map<String, String> fields = _getSummaryFields(programSummary);
357
358                    for (String keptField : _searchFields)
359                    {
360                        String field = fields.get(keptField);
361                        result.put(keptField, field);
362                    }
363
364                    result.put(SCC_UNIQUE_ID, pegaseSyncCode);
365                }
366            }
367        }
368        catch (ApiException | IOException e)
369        {
370            throw new RuntimeException("Error while getting remote values", e);
371        }
372        
373        return results;
374    }
375    
376    @Override
377    public int getTotalCount(Map<String, Object> searchParameters, Logger logger)
378    {
379        // avoid to relaunch a full search if already done
380        Long totalCount = (Long) searchParameters.get("totalCount");
381        
382        if (totalCount != null)
383        {
384            return totalCount.intValue();
385        }
386        
387        return super.getTotalCount(searchParameters, logger);
388    }
389    
390    private PagedObjetMaquetteSummaries _getPagedProgramsSummaries(Map<String, Object> parameters, int offset, int limit, List<Object> sort, ObjetsMaquetteExterneApi objetsMaquetteApi) throws ApiException
391    {
392        Pageable pageable = new Pageable();
393        int pageNumber = offset / 50;
394        pageable.setPage(pageNumber);
395        pageable.setTaille(limit);
396        
397        if (sort == null)
398        {
399            pageable.setTri(List.of());
400        }
401        else
402        {
403            // Convert the sort parameter from Object to JSON
404            String jsonSortParameters = _jsonUtils.convertObjectToJson(sort.get(0));
405            // Convert the sort parameter from JSON to Map<String, Object>
406            Map<String, Object> sortParameters = _jsonUtils.convertJsonToMap(jsonSortParameters);
407            // Create the list containing the sort result; it is going to be of the form : ["field,direction"]
408            List<String> sortParametersArray = new ArrayList<>();
409            // Create the sort parameter that is going to be sent in the list; it is going to be of the form : "field,direction"
410            StringBuilder stringBuilder = new StringBuilder();
411            // Get the parameter "property" which is the column on which the sorting is meant to be made
412            String property = (String) sortParameters.get("property");
413            
414            if (!"code".equals(property) && !"libelle".equals(property))
415            {
416                if ("libelle".equals(property))
417                {
418                    stringBuilder.append("libelle,").append((String) sortParameters.get("direction"));
419                    sortParametersArray.add(stringBuilder.toString());
420                }
421            }
422            else
423            {
424                stringBuilder.append(property).append(",").append((String) sortParameters.get("direction"));
425                sortParametersArray.add(stringBuilder.toString());
426            }
427            
428            pageable.setTri(sortParametersArray);
429        }
430        
431        String searchLabel = (String) parameters.get("libelle");
432        List<TypeObjetMaquette> typeObjets = List.of(TypeObjetMaquette.FORMATION);
433        Boolean validated = (Boolean) parameters.get("validee");
434        return objetsMaquetteApi.rechercherObjetMaquette(_structureCode, pageable, searchLabel, null, typeObjets, null, null, null, null, null, validated, null);
435    }
436
437    private Map<String, String> _getSummaryFields(ObjetMaquetteSummary objectSummary)
438    {
439        return Map.of("id", objectSummary.getId().toString(),
440                      "code",  StringUtils.defaultString(objectSummary.getCode()),
441                      "libelle", StringUtils.defaultString(objectSummary.getLibelle()),
442                      "espace", StringUtils.defaultString(objectSummary.getEspaceLibelle()));
443    }
444    
445    @SuppressWarnings("unchecked")
446    @Override
447    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
448    {
449        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
450
451        List<String> pegaseSyncCodeValues = (List<String>) parameters.getOrDefault(getIdColumn(), new ArrayList<>());
452        
453        List<String> idValues = new ArrayList<>();
454        
455        try
456        {
457            ObjetsMaquetteExterneApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteExterneApi();
458            MaquettesExterneApi maquettesApi = _pegaseApiManager.getMaquettesExterneApi();
459
460            if (!pegaseSyncCodeValues.isEmpty())
461            {
462                // import or synchronize single items
463                idValues.addAll(pegaseSyncCodeValues);
464            }
465            else
466            {
467                // global synchronization
468                PagedObjetMaquetteSummaries pagedPrograms = _getPagedProgramsSummaries(parameters, 0, Integer.MAX_VALUE, null, objetsMaquetteApi);
469                for (ObjetMaquetteSummary programDetails : pagedPrograms.getItems())
470                {
471                    idValues.add(programDetails.getId().toString());
472                }
473            }
474            
475            results.putAll(_getObjectDetailsForImport(idValues, objetsMaquetteApi, maquettesApi, logger));
476        }
477        catch (ApiException | IOException e)
478        {
479            throw new RuntimeException("Error while getting remote values", e);
480        }
481        
482        return results;
483    }
484
485    Map<String, Map<String, List<Object>>> _getObjectDetailsForImport(List<String> idValues, ObjetsMaquetteExterneApi objetsMaquetteApi, MaquettesExterneApi maquettesApi, Logger logger) throws ApiException
486    {
487        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
488        Set<UUID> alreadyHandledObjects = new HashSet<>();
489
490        for (String idValue : idValues)
491        {
492            // Get the program tree structure from the idValue
493            MaquetteStructure maquette = maquettesApi.lireStructureMaquette(_structureCode, UUID.fromString(idValue));
494            ObjetMaquetteStructure racine = maquette.getRacine();
495            
496            results.putAll(_getObjectDetailsForImport(racine, null, alreadyHandledObjects, objetsMaquetteApi, logger));
497        }
498        
499        return results;
500    }
501    
502    private Map<String, Map<String, List<Object>>> _getObjectDetailsForImport(ObjetMaquetteStructure item, EnfantsStructure structure, Set<UUID> alreadyHandledObjects, ObjetsMaquetteExterneApi objetsMaquetteApi, Logger logger) throws ApiException
503    {
504        Map<String, Map<String, List<Object>>> results = new LinkedHashMap<>();
505        
506        UUID id = item.getId();
507        
508        if (alreadyHandledObjects.add(id))
509        {
510            ObjetMaquetteDetail detail = objetsMaquetteApi.lireObjetMaquette(_structureCode, id);
511            
512            // get data for this particular Pegase object
513            Map<String, List<Object>> objectData = _getObjectFields(detail, structure, item);
514            
515            // then compute Ametys children from the Pegase structure
516            ComputedChildren children = _computeChildren(item);
517            
518            // link to Ametys children
519            objectData.put("children", children.childrenIds());
520            
521            results.put(id.toString(), objectData);
522            
523            // add the newly created intermediary objects, if any
524            results.putAll(children.newObjects());
525            
526            // then finally import recursively next objects
527            for (EnfantsStructure child : children.nextObjects())
528            {
529                results.putAll(_getObjectDetailsForImport(child.getObjetMaquette(), child, alreadyHandledObjects, objetsMaquetteApi, logger));
530            }
531        }
532        
533        return results;
534    }
535    
536    private ComputedChildren _computeChildren(ObjetMaquetteStructure item)
537    {
538        List<EnfantsStructure> enfants = item.getEnfants();
539        
540        List<Object> childrenIds = new ArrayList<>();
541        List<EnfantsStructure> nextObjects = new ArrayList<>();
542        Map<String, Map<String, List<Object>>> newObjects = new HashMap<>();
543        
544        if (_isProgramPart(item))
545        {
546            // item is a programpart, we allow other programparts as direct children
547            List<EnfantsStructure> programPartChildren = enfants.stream().filter(e -> _isProgramPart(e.getObjetMaquette())).toList();
548            childrenIds.addAll(programPartChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
549            nextObjects.addAll(programPartChildren);
550            
551            // if there are direct courses, we should inject an intermediary list
552            _computeIntermediateLists(item, enfants, childrenIds, nextObjects, newObjects);
553            
554            // add groups
555            List<EnfantsStructure> listChildren = enfants.stream().filter(e -> _isCourseList(e.getObjetMaquette())).toList();
556            childrenIds.addAll(listChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
557            nextObjects.addAll(listChildren);
558        }
559        else if (_isCourseList(item))
560        {
561            List<EnfantsStructure> courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette())).toList();
562            childrenIds.addAll(courseChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
563            nextObjects.addAll(courseChildren);
564        }
565        else if (_isCourse(item))
566        {
567            // if there are direct courses, we should inject an intermediary list
568            _computeIntermediateLists(item, enfants, childrenIds, nextObjects, newObjects);
569            
570            // add groups
571            List<EnfantsStructure> listChildren = enfants.stream().filter(e -> _isCourseList(e.getObjetMaquette())).toList();
572            childrenIds.addAll(listChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).toList());
573            nextObjects.addAll(listChildren);
574        }
575        
576        return new ComputedChildren(childrenIds, newObjects, nextObjects);
577    }
578    
579    private String _getContentType(ObjetMaquetteStructure item)
580    {
581        String type = item.getType();
582        if (item.getClasse().equals("F"))
583        {
584            type = "FORMATION";
585        }
586        else if (item.getClasse().equals("G"))
587        {
588            type = "GROUPEMENT";
589        }
590        
591        return Optional.ofNullable(type)
592                .map(_pegaseSccMappingHelper::getAmetysType)
593                .orElse(CourseFactory.COURSE_CONTENT_TYPE);
594    }
595    
596    private boolean _isProgramPart(ObjetMaquetteStructure item)
597    {
598        String contentType = _getContentType(item);
599        
600        if (contentType.equals(ProgramFactory.PROGRAM_CONTENT_TYPE) || contentType.equals(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE) || contentType.equals(ContainerFactory.CONTAINER_CONTENT_TYPE))
601        {
602            return true;
603        }
604        
605        return false;
606    }
607    
608    private boolean _isCourse(ObjetMaquetteStructure item)
609    {
610        return _getContentType(item).equals(CourseFactory.COURSE_CONTENT_TYPE);
611    }
612    
613    private boolean _isCourseList(ObjetMaquetteStructure item)
614    {
615        return _getContentType(item).equals(CourseListFactory.COURSE_LIST_CONTENT_TYPE);
616    }
617    
618    private void _computeIntermediateLists(ObjetMaquetteStructure item, List<EnfantsStructure> enfants, List<Object> childrenIds, List<EnfantsStructure> nextObjects, Map<String, Map<String, List<Object>>> newObjects)
619    {
620        List<EnfantsStructure> courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette()) && e.getObligatoire()).toList();
621        _computeIntermediateList(item, courseChildren, true, childrenIds, nextObjects, newObjects);
622        
623        courseChildren = enfants.stream().filter(e -> _isCourse(e.getObjetMaquette()) && !e.getObligatoire()).toList();
624        _computeIntermediateList(item, courseChildren, false, childrenIds, nextObjects, newObjects);
625    }
626    
627    private void _computeIntermediateList(ObjetMaquetteStructure item, List<EnfantsStructure> courseChildren, boolean mandatory, List<Object> childrenIds, List<EnfantsStructure> nextObjects, Map<String, Map<String, List<Object>>> newObjects)
628    {
629        if (!courseChildren.isEmpty())
630        {
631            String listId = item.getId() + __INSERTED_GROUPEMENT_SUFFIX + (mandatory ? "O" : "F");
632            childrenIds.add(listId);
633            
634            Map<String, List<Object>> listData = new HashMap<>();
635            listData.put("workflowDescription", List.of(ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION));
636            listData.put(getIdField(), List.of(listId));
637            listData.put("title", List.of(item.getLibelle() + " - Liste " + (mandatory ? "O" : "F")));
638            listData.put("obligatoire", List.of(String.valueOf(mandatory)));
639            listData.put("plageDeChoix", List.of("false"));
640            listData.put("children", courseChildren.stream().map(e -> e.getObjetMaquette().getId().toString()).collect(Collectors.toList()));
641            
642            newObjects.put(listId, listData);
643            nextObjects.addAll(courseChildren);
644        }
645    }
646    
647    private Map<String, List<Object>> _getObjectFields(ObjetMaquetteDetail objectDetails, EnfantsStructure structure, ObjetMaquetteStructure item)
648    {
649        Map<String, List<Object>> result = new HashMap<>();
650        
651        String contentTypeId = _getContentType(item);
652        ContentWorkflowDescription wfDescription = ContentWorkflowDescription.getByContentType(contentTypeId);
653        
654        result.put("workflowDescription", List.of(wfDescription));
655        
656        Map<String, String> contentTypeMapping = _mappingByContentType.get(contentTypeId);
657        
658        Map<String, Object> fields = _getFields(objectDetails, structure);
659        
660        for (String attribute : contentTypeMapping.keySet())
661        {
662            String jsonKey = contentTypeMapping.get(attribute);
663            Object value = fields.get(jsonKey);
664            
665            result.put(attribute, value != null ? List.of(value) : null); // Add the retrieved metadata values list to the contentResult
666        }
667        
668        // Add the ID field
669        result.put(getIdField(), List.of(objectDetails.getId().toString()));
670        
671        return result;
672    }
673    
674    private Map<String, Object> _getFields(ObjetMaquetteDetail objectDetails, EnfantsStructure structure)
675    {
676        Map<String, Object> fields = new HashMap<>();
677        
678        fields.put("id", objectDetails.getId().toString());
679        fields.put("code",  StringUtils.trimToNull(objectDetails.getCode()));
680        
681        DescripteursObjetMaquette descripteursObjetMaquette = objectDetails.getDescripteursObjetMaquette();
682        String libelle = StringUtils.trimToNull(descripteursObjetMaquette.getLibelle());
683        fields.put("libelle", libelle);
684        fields.put("libelleLong", StringUtils.trimToNull(descripteursObjetMaquette.getLibelleLong()));
685        
686        if (descripteursObjetMaquette instanceof DescripteursFormation descripteursFormation)
687        {
688            fields.put("ects", descripteursFormation.getEcts());
689            fields.put("orgUnit", StringUtils.trimToNull(descripteursFormation.getStructurePrincipale()));
690        }
691        else if (descripteursObjetMaquette instanceof DescripteursGroupement descripteursGroupement)
692        {
693            fields.put("obligatoire", String.valueOf(structure.getObligatoire()));
694            
695            PlageDeChoix plageDeChoix = descripteursGroupement.getPlageDeChoix();
696            if (plageDeChoix != null)
697            {
698                fields.put("plageDeChoix", "true");
699                fields.put("plageMin", descripteursGroupement.getPlageDeChoix().getMin());
700                fields.put("plageMax", descripteursGroupement.getPlageDeChoix().getMax());
701            }
702        }
703        else if (descripteursObjetMaquette instanceof DescripteursObjetFormation descripteursObjetFormation)
704        {
705            fields.put("ects", descripteursObjetFormation.getEcts());
706            fields.put("orgUnit", StringUtils.trimToNull(descripteursObjetFormation.getStructurePrincipale()));
707            fields.put("capaciteAccueil", descripteursObjetFormation.getCapaciteAccueil());
708            
709            String type = _getNomenclature(descripteursObjetFormation.getType());
710            fields.put("typeObjetFormation", type);
711        }
712        
713        DescripteursSyllabus syllabus = objectDetails.getDescripteursSyllabus();
714        if (syllabus != null)
715        {
716            fields.put("langueEnseignement", StringUtils.trimToNull(syllabus.getLangueEnseignement()));
717            fields.put("description", StringUtils.trimToNull(syllabus.getDescription()));
718            fields.put("bibliographie", StringUtils.trimToNull(syllabus.getBibliographie()));
719            fields.put("autresInformations", StringUtils.trimToNull(syllabus.getAutresInformations()));
720            fields.put("ouvertureALaMobiliteEntrante", syllabus.getOuvertureALaMobiliteEntrante());
721            fields.put("objectif", StringUtils.trimToNull(syllabus.getObjectif()));
722            fields.put("prerequisPedagogique", StringUtils.trimToNull(syllabus.getPrerequisPedagogique()));
723            
724            if (syllabus instanceof DescripteursObjetFormationSyllabus objetFormationSyllabus)
725            {
726                fields.put("modalitesEvaluation", objetFormationSyllabus.getModalitesEvaluation());
727            }
728        }
729        
730        DescripteursSise sise = objectDetails.getDescripteursEnquete().getDescripteursSise();
731        if (sise != null)
732        {
733            fields.put("codeTypeDiplome", _getNomenclature(sise.getTypeDiplome()));
734            fields.put("codeMention", _getNomenclature(sise.getMention()));
735            fields.put("codeNiveauDiplome", _getNomenclature(sise.getNiveauDiplome()));
736            fields.put("codeChampFormation", _getNomenclature(sise.getChampFormation()));
737            fields.put("codeDomaineFormation", _getNomenclature(sise.getDomaineFormation()));
738        }
739        
740        return fields;
741    }
742    
743    private String _getNomenclature(Nomenclature nomenclature)
744    {
745        return nomenclature != null ? StringUtils.trimToNull(nomenclature.getCode()) : null;
746    }
747    
748    @Override
749    public List<ModifiableContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
750    {
751        Map<String, Object> parameters = putIdParameter(idValue);
752        return _importOrSynchronizeContents(parameters, true, logger);
753    }
754    
755    @Override
756    public void synchronizeContent(ModifiableContent content, Logger logger) throws Exception
757    {
758        String idValue = content.getValue(getIdField());
759        Map<String, Object> parameters = putIdParameter(idValue);
760        _importOrSynchronizeContents(parameters, true, logger);
761    }
762    
763    @Override
764    protected List<ModifiableContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger, ContainerProgressionTracker progressionTracker)
765    {
766        _importedContents = new HashMap<>();
767        _synchronizedContents = new HashSet<>();
768        _updatedRelationContents = new HashSet<>();
769        _contentsChildren = new HashMap<>();
770        
771        try
772        {
773            _cdmfrHandler.suspendCDMFRObserver();
774            
775            List<ModifiableContent> contents =  super._importOrSynchronizeContents(searchParams, forceImport, logger, progressionTracker);
776            _updateRelations(logger);
777            _updateWorkflowStatus(logger);
778            return contents;
779        }
780        finally
781        {
782            _cdmfrHandler.unsuspendCDMFRObserver(_synchronizedContents);
783            
784            _importedContents = null;
785            _synchronizedContents = null;
786            _updatedRelationContents = null;
787            _contentsChildren = null;
788        }
789    }
790    
791    /**
792     * Get or create the content defined by the given parameters.
793     * @param lang The content language
794     * @param idValue The synchronization code
795     * @param remoteValues The remote values
796     * @param forceImport <code>true</code> to force import (only on single import or unlimited global synchronization)
797     * @param logger The logger
798     * @return the content
799     * @throws Exception if an error occurs
800     */
801    protected ModifiableContent _getOrCreateContent(String lang, String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) throws Exception
802    {
803        ContentWorkflowDescription wfDescription = (ContentWorkflowDescription) remoteValues.get("workflowDescription").get(0);
804        ModifiableContent content = _getContent(lang, idValue, wfDescription.getContentType());
805        if (content == null && (forceImport || !synchronizeExistingContentsOnly()))
806        {
807            // Calculate contentTitle
808            String contentTitle = Optional.of(remoteValues)
809                .map(v -> v.get("title"))
810                .map(List::stream)
811                .orElseGet(Stream::empty)
812                .filter(String.class::isInstance)
813                .map(String.class::cast)
814                .filter(StringUtils::isNotEmpty)
815                .findFirst()
816                .orElse(idValue);
817            
818            String contentName = NameHelper.filterName(_contentPrefix + "-" + contentTitle + "-" + lang);
819            
820            Map<String, Object> inputs = new HashMap<>();
821            String catalog = getCatalog();
822            if (catalog != null)
823            {
824                inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, catalog);
825            }
826            
827            Map<String, Object> resultMap = _contentWorkflowHelper.createContent(
828                    wfDescription.getWorkflowName(),
829                    wfDescription.getInitialActionId(),
830                    contentName,
831                    contentTitle,
832                    new String[] {wfDescription.getContentType()},
833                    null,
834                    lang,
835                    inputs);
836            
837            if ((boolean) resultMap.getOrDefault("error", false))
838            {
839                _nbError++;
840            }
841            
842            content = (ModifiableContent) resultMap.get(Content.class.getName());
843
844            if (content != null)
845            {
846                _sccHelper.updateSCCProperty(content, getId());
847                
848                // Set sync code
849                content.setValue(getIdField(), idValue);
850                
851                content.saveChanges();
852                _importedContents.put(content.getId(), wfDescription.getValidationActionId());
853                _nbCreatedContents++;
854            }
855        }
856        return content;
857    }
858    
859    /**
860     * Get the content from the synchronization code, the lang, the catalog and the content type.
861     * @param lang The lang
862     * @param syncCode The synchronization code
863     * @param contentType The content type
864     * @return the retrieved content
865     */
866    protected ModifiableContent _getContent(String lang, String syncCode, String contentType)
867    {
868        String xPathQuery = _getContentPathQuery(lang, syncCode, contentType, false);
869        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
870
871        if (contents.getSize() > 0)
872        {
873            return contents.iterator().next();
874        }
875        
876        return null;
877    }
878    
879    @Override
880    protected Optional<ModifiableContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
881    {
882        try
883        {
884            ModifiableContent content = _getOrCreateContent(lang, idValue, remoteValues, forceImport, logger);
885            if (content != null)
886            {
887                return Optional.of(_synchronizeContent(content, remoteValues, logger));
888            }
889        }
890        catch (Exception e)
891        {
892            _nbError++;
893            logger.error("An error occurred while importing or synchronizing content", e);
894        }
895        
896        return Optional.empty();
897    }
898    
899    @SuppressWarnings("unchecked")
900    @Override
901    protected ModifiableContent _synchronizeContent(ModifiableContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
902    {
903        super._synchronizeContent(content, remoteValues, logger);
904        // Add children to the list to handle later to add relations
905        if (remoteValues.containsKey("children"))
906        {
907            Set<String> children = _contentsChildren.computeIfAbsent(content.getId(), __ -> new LinkedHashSet<>());
908            children.addAll((List<String>) (Object) remoteValues.get("children"));
909        }
910        return content;
911    }
912    
913    @Override
914    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableContent content, Map<String, Object> additionalParameters, boolean create, Logger logger) throws Exception
915    {
916        _synchronizedContents.add(content.getId());
917        return super._fillContent(remoteValues, content, additionalParameters, create, logger);
918    }
919    
920    @Override
921    public List<String> getLanguages()
922    {
923        return List.of(_odfLang);
924    }
925
926    @Override
927    protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType, boolean forceStrictCheck)
928    {
929        List<Expression> expList = super._getExpressionsList(lang, idValue, contentType, forceStrictCheck);
930        String catalog = getCatalog();
931        if (catalog != null)
932        {
933            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
934        }
935        return expList;
936    }
937    
938    @Override
939    protected Map<String, Object> _transformRemoteValuesCardinality(Map<String, List<Object>> remoteValues, String obsoleteContentTypeId)
940    {
941        String realContentTypeId = Optional.of(remoteValues)
942                .map(v -> v.get("workflowDescription"))
943                .map(l -> l.get(0))
944                .map(ContentWorkflowDescription.class::cast)
945                .map(ContentWorkflowDescription::getContentType)
946                .orElse(null);
947        return super._transformRemoteValuesCardinality(remoteValues, realContentTypeId);
948    }
949    
950    private void _updateRelations(Logger logger)
951    {
952        for (String contentId : _contentsChildren.keySet())
953        {
954            WorkflowAwareContent content = _resolver.resolveById(contentId);
955            Set<String> childrenCodes = _contentsChildren.get(contentId);
956            String contentLanguage = content.getLanguage();
957            String contentCatalog = content.getValue("catalog");
958            Map<String, Set<String>> childrenByAttributeName = new HashMap<>();
959            
960            for (String childCode : childrenCodes)
961            {
962                Expression expression = new AndExpression(
963                    _sccHelper.getCollectionExpression(getId()),
964                    new StringExpression(getIdField(), Operator.EQ, childCode),
965                    new StringExpression("catalog", Operator.EQ, contentCatalog),
966                    new LanguageExpression(Operator.EQ, contentLanguage)
967                );
968                
969                ModifiableContent childContent = _resolver.<ModifiableContent>query(ContentQueryHelper.getContentXPathQuery(expression))
970                    .stream()
971                    .findFirst()
972                    .orElse(null);
973                
974                if (childContent == null)
975                {
976                    logger.warn("Content with code '{}' in {} on catalog '{}' was not found in the repository to update relations with content [{}] '{}' ({}).", childCode, contentLanguage, contentCatalog, content.getValue(getIdField()), content.getTitle(), contentCatalog);
977                }
978                else
979                {
980                    String attributesName = _getChildAttributeName(content, childContent);
981                    if (attributesName != null)
982                    {
983                        Set<String> children = childrenByAttributeName.computeIfAbsent(attributesName, __ -> new LinkedHashSet<>());
984                        children.add(childContent.getId());
985                    }
986                    else
987                    {
988                        logger.warn("The child content [{}] '{}' of type '{}' is not compatible with parent content [{}] '{}' of type '{}'.", childCode, childContent.getTitle(), childContent.getTypes()[0], content.getValue(getIdField()), content.getTitle(), content.getTypes()[0]);
989                    }
990                }
991            }
992            _updateRelations(content, childrenByAttributeName, logger);
993        }
994    }
995    
996    private String _getChildAttributeName(Content parentContent, Content childContent)
997    {
998        if (childContent instanceof Course && parentContent instanceof CourseList)
999        {
1000            return CourseList.CHILD_COURSES;
1001        }
1002        
1003        if (parentContent instanceof Course && childContent instanceof CourseList)
1004        {
1005            return Course.CHILD_COURSE_LISTS;
1006        }
1007        
1008        if (parentContent instanceof TraversableProgramPart && childContent instanceof ProgramPart)
1009        {
1010            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
1011        }
1012        
1013        return null;
1014    }
1015    
1016    private void _updateRelations(WorkflowAwareContent content, Map<String, Set<String>> contentRelationsByAttribute, Logger logger)
1017    {
1018        // Compute the view
1019        View view = View.of(content.getModel(), contentRelationsByAttribute.keySet().toArray(new String[contentRelationsByAttribute.size()]));
1020        
1021        // Compute values
1022        Map<String, Object> values = new HashMap<>();
1023        for (String attributeName : contentRelationsByAttribute.keySet())
1024        {
1025            // Add the content relations to the existing ones
1026            List<String> attributeValue = _getContentAttributeValue(content, attributeName);
1027            contentRelationsByAttribute.get(attributeName)
1028                .stream()
1029                .filter(id -> !attributeValue.contains(id))
1030                .forEach(attributeValue::add);
1031            
1032            values.put(attributeName, attributeValue.toArray(new String[attributeValue.size()]));
1033        }
1034        
1035        // Compute not synchronized contents
1036        Set<String> notSynchronizedContentIds = contentRelationsByAttribute.values()
1037                .stream()
1038                .flatMap(Collection::stream)
1039                .filter(id -> !_synchronizedContents.contains(id))
1040                .collect(Collectors.toSet());
1041        
1042        try
1043        {
1044            _editContent(content, Optional.of(view), values, Map.of(), false, notSynchronizedContentIds, logger);
1045        }
1046        catch (WorkflowException e)
1047        {
1048            _nbError++;
1049            logger.error("The content '{}' cannot be links edited (workflow action)", content, e);
1050        }
1051    }
1052    
1053    private List<String> _getContentAttributeValue(Content content, String attributeName)
1054    {
1055        return Optional.of(attributeName)
1056            .map(content::<ContentValue[]>getValue)
1057            .map(Stream::of)
1058            .orElseGet(Stream::empty)
1059            .map(ContentValue::getContentId)
1060            .collect(Collectors.toList());
1061    }
1062    
1063    private void _updateWorkflowStatus(Logger logger)
1064    {
1065        // Validate contents -> only on newly imported contents
1066        if (validateAfterImport())
1067        {
1068            for (String contentId : _importedContents.keySet())
1069            {
1070                WorkflowAwareContent content = _resolver.resolveById(contentId);
1071                Integer validationActionId = _importedContents.get(contentId);
1072                if (validationActionId > 0)
1073                {
1074                    validateContent(content, validationActionId, logger);
1075                }
1076            }
1077        }
1078    }
1079    
1080    @Override
1081    public boolean handleRightAssignmentContext()
1082    {
1083        // Rights on ODF contents are handled by ODFRightAssignmentContext
1084        return false;
1085    }
1086    
1087    @Override
1088    public ContentSynchronizationResult additionalCommonOperations(ModifiableContent content, Map<String, Object> additionalParameters, Logger logger)
1089    {
1090        _setPeriod(content);
1091        return super.additionalCommonOperations(content, additionalParameters, logger);
1092    }
1093    
1094    private void _setPeriod(ModifiableContent content)
1095    {
1096        if (content instanceof Container container && "semestre".equals(_resolver.<Content>resolveById(container.getNature()).getValue("code")))
1097        {
1098            String period = _getPeriodFromTitle(content);
1099            if (period != null)
1100            {
1101                OdfReferenceTableEntry entry = _refTableHelper.getItemFromCode(OdfReferenceTableHelper.PERIOD, period);
1102                if (entry != null)
1103                {
1104                    content.setExternalValue(Container.PERIOD, entry.getId());
1105                }
1106            }
1107        }
1108    }
1109    
1110    private String _getPeriodFromTitle(Content content)
1111    {
1112        return switch (content.getTitle())
1113        {
1114            case String title when StringUtils.containsIgnoreCase(title, "Semestre 1") || StringUtils.containsIgnoreCase(title, "S1") -> "s1";
1115            case String title when StringUtils.containsIgnoreCase(title, "Semestre 2") || StringUtils.containsIgnoreCase(title, "S2") -> "s2";
1116            case String title when StringUtils.containsIgnoreCase(title, "Semestre 3") || StringUtils.containsIgnoreCase(title, "S3") -> "s3";
1117            case String title when StringUtils.containsIgnoreCase(title, "Semestre 4") || StringUtils.containsIgnoreCase(title, "S4") -> "s4";
1118            case String title when StringUtils.containsIgnoreCase(title, "Semestre 5") || StringUtils.containsIgnoreCase(title, "S5") -> "s5";
1119            case String title when StringUtils.containsIgnoreCase(title, "Semestre 6") || StringUtils.containsIgnoreCase(title, "S6") -> "s6";
1120            default -> null;
1121        };
1122    }
1123    
1124    private record ComputedChildren(List<Object> childrenIds, Map<String, Map<String, List<Object>>> newObjects, List<EnfantsStructure> nextObjects) { /* empty*/ }
1125}