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