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.math.BigDecimal;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.LinkedHashMap;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
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.ContentValue;
043import org.ametys.cms.repository.Content;
044import org.ametys.cms.repository.ContentQueryHelper;
045import org.ametys.cms.repository.LanguageExpression;
046import org.ametys.cms.repository.ModifiableContent;
047import org.ametys.cms.repository.WorkflowAwareContent;
048import org.ametys.core.util.JSONUtils;
049import org.ametys.odf.ProgramItem;
050import org.ametys.odf.catalog.CatalogsManager;
051import org.ametys.odf.course.Course;
052import org.ametys.odf.course.CourseFactory;
053import org.ametys.odf.courselist.CourseList;
054import org.ametys.odf.courselist.CourseListFactory;
055import org.ametys.odf.program.ContainerFactory;
056import org.ametys.odf.program.ProgramFactory;
057import org.ametys.odf.program.ProgramPart;
058import org.ametys.odf.program.SubProgramFactory;
059import org.ametys.odf.program.TraversableProgramPart;
060import org.ametys.odf.workflow.AbstractCreateODFContentFunction;
061import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
062import org.ametys.plugins.odfsync.pegase.ws.PegaseApiManager;
063import org.ametys.plugins.odfsync.utils.ContentWorkflowDescription;
064import org.ametys.plugins.repository.AmetysObjectIterable;
065import org.ametys.plugins.repository.jcr.NameHelper;
066import org.ametys.plugins.repository.query.expression.AndExpression;
067import org.ametys.plugins.repository.query.expression.Expression;
068import org.ametys.plugins.repository.query.expression.Expression.Operator;
069import org.ametys.plugins.repository.query.expression.StringExpression;
070import org.ametys.runtime.config.Config;
071import org.ametys.runtime.i18n.I18nizableText;
072import org.ametys.runtime.model.View;
073
074import com.opensymphony.workflow.WorkflowException;
075
076import fr.pcscol.pegase.cof.ApiException;
077import fr.pcscol.pegase.cof.api.ArbresApi;
078import fr.pcscol.pegase.cof.api.ObjetsMaquetteApi;
079import fr.pcscol.pegase.cof.model.Arbre;
080import fr.pcscol.pegase.cof.model.Enfant;
081import fr.pcscol.pegase.cof.model.ObjetLibelle;
082import fr.pcscol.pegase.cof.model.ObjetMaquette;
083import fr.pcscol.pegase.cof.model.ObjetMaquetteDetails;
084import fr.pcscol.pegase.cof.model.ObjetMaquetteFormation;
085import fr.pcscol.pegase.cof.model.ObjetMaquetteFormationTypeFormation;
086import fr.pcscol.pegase.cof.model.ObjetMaquetteGroupement;
087import fr.pcscol.pegase.cof.model.Pageable;
088import fr.pcscol.pegase.cof.model.PagedObjetMaquette;
089import fr.pcscol.pegase.cof.model.Ref;
090
091/**
092 * SCC for Pegase (COF).
093 */
094public class PegaseSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection
095{
096    private static final String __INSERTED_GROUPEMENT_SUFFIX = "-LIST";
097    private static final int __PEGASE_SYNC_CODE_MAX_LENGTH = 25;
098    
099    /** The JSON utils */
100    protected JSONUtils _jsonUtils;
101    
102    /** The catalogs manager */
103    protected CatalogsManager _catalogsManager;
104    
105    /** The Pégase API manager */
106    protected PegaseApiManager _pegaseApiManager;
107    
108    /** Mapping between metadata and columns */
109    protected Map<String, Map<String, List<String>>> _mappingByContentType;
110    
111    /** Search fields to display */
112    protected Set<String> _searchFields;
113
114    /** List of imported contents */
115    protected Map<String, Integer> _importedContents;
116    
117    /** List of synchronized contents having differences */
118    protected Set<String> _synchronizedContents;
119    
120    /** List of updated contents by relation */
121    protected Set<String> _updatedRelationContents;
122    
123    /** Map to link contents to its children at the end of the process */
124    protected Map<String, Set<String>> _contentsChildren;
125    
126    /** Default language configured for ODF */
127    protected String _odfLang;
128    
129    /** The structure code for Pégase */
130    protected String _structureCode;
131    
132    @Override
133    public void service(ServiceManager manager) throws ServiceException
134    {
135        super.service(manager);
136        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
137        _catalogsManager = (CatalogsManager) manager.lookup(CatalogsManager.ROLE);
138        _pegaseApiManager = (PegaseApiManager) manager.lookup(PegaseApiManager.ROLE);
139    }
140    
141    @Override
142    public String getIdField()
143    {
144        return "pegaseSyncCode";
145    }
146
147    /**
148     * Get the identifier JSON field.
149     * @return the column id
150     */
151    protected String getIdColumn()
152    {
153        return "id";
154    }
155
156    @SuppressWarnings("unchecked")
157    @Override
158    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
159    {
160        return Optional.ofNullable(additionalParameters)
161            .map(params -> params.get("contentTypes"))
162            .filter(List.class::isInstance)
163            .map(cTypes -> (List<String>) cTypes)
164            .filter(Predicate.not(List::isEmpty))
165            .map(l -> l.get(0))
166            .map(_mappingByContentType::get)
167            .map(Map::keySet)
168            .orElse(Set.of());
169    }
170
171    @Override
172    protected Map<String, Object> putIdParameter(String idValue)
173    {
174        Map<String, Object> parameters = new HashMap<>();
175        parameters.put(getIdColumn(), List.of(idValue));
176        return parameters;
177    }
178    
179    @SuppressWarnings("unchecked")
180    @Override
181    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
182    {
183        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
184        
185        try
186        {
187            ObjetsMaquetteApi objetsMaquetteApi = _pegaseApiManager.getObjetsMaquetteApi();
188            ArbresApi arbresApi = _pegaseApiManager.getArbresApi();
189            
190            boolean importMode = (boolean) parameters.getOrDefault("import", false);
191            List<String> pegaseSyncCodeValues = (List<String>) parameters.getOrDefault(getIdColumn(), new ArrayList<>());
192            
193            if (importMode)
194            {
195                Map<String, String> idValues = new HashMap<>();
196                if (!pegaseSyncCodeValues.isEmpty())
197                {
198                    for (String pegaseSyncCode : pegaseSyncCodeValues)
199                    {
200                        // Get details for each requested programs by objetsMaquetteApi
201                        ObjetMaquette programDetails = _getProgramDetails(pegaseSyncCode, objetsMaquetteApi);
202                        idValues.put(programDetails.getId(), programDetails.getCode());
203                    }
204                }
205                else
206                {
207                    // Get details for all requested programs by objetsMaquetteApi
208                    PagedObjetMaquette pagedPrograms = _getPagedProgramsDetails(parameters, offset, limit, sort, objetsMaquetteApi);
209                    for (ObjetMaquette programDetails : pagedPrograms.getItems())
210                    {
211                        idValues.put(programDetails.getId(), programDetails.getCode());
212                    }
213                }
214                
215                results.putAll(_getObjectDetailsForImport(idValues, objetsMaquetteApi, arbresApi, logger));
216            }
217            else if (pegaseSyncCodeValues.isEmpty())
218            {
219                // Get details for all requested programs by objetsMaquetteApi
220                PagedObjetMaquette pagedPrograms = _getPagedProgramsDetails(parameters, offset, limit, sort, objetsMaquetteApi);
221                for (ObjetMaquette programDetails : pagedPrograms.getItems())
222                {
223                    String pegaseSyncCode = _createPegaseSyncCode(programDetails);
224                    
225                    // If not an import, fill the results with Pégase synchronization code
226                    Map<String, Object> result = results.computeIfAbsent(pegaseSyncCode, __ -> new HashMap<>());
227                    Map<String, String> fields = _getFields(programDetails);
228
229                    for (String keptField : _searchFields)
230                    {
231                        String field = fields.get(keptField);
232                        result.put(keptField, field);
233                    }    
234
235                    result.put(SCC_UNIQUE_ID, pegaseSyncCode);
236                }
237            }
238        }
239        catch (ApiException | IOException e)
240        {
241            throw new RuntimeException("Error while getting remote values", e);
242        }
243        
244        return results;
245    }
246    
247    private ObjetMaquette _getProgramDetails(String pegaseSyncCode, ObjetsMaquetteApi objetsMaquetteApi) throws ApiException
248    {
249        String[] split = pegaseSyncCode.split("/");
250        
251        if (split.length != 2)
252        {
253            // If the given code is not well formatted, throw an exception
254            throw new ApiException("Le code de synchronisation n'est pas valide, il doit être de la forme [code]/[version] mais il a comme valeur : " + pegaseSyncCode);
255        }
256        
257        String code = split[0];
258        BigDecimal version = new BigDecimal(split[1]);
259        
260        PagedObjetMaquette pagedObjectDetails = objetsMaquetteApi.lireListeObjetsMaquette(_structureCode, "FORMATION", null, null, code, null, null, null, null, null, null, version, null, null, null, null, null, null, null);
261        
262        if (pagedObjectDetails != null)
263        {
264            List<ObjetMaquette> items = pagedObjectDetails.getItems();
265            if (items != null && !items.isEmpty())
266            {
267                // Retrieve first found formation
268                return items.get(0);
269            }
270        }
271        
272        // If no formation has been found with the given code, throw an exception
273        throw new ApiException("Aucune formation n'a été trouvée avec le code de synchronisation " + pegaseSyncCode);
274    }
275    
276    @SuppressWarnings("unchecked")
277    private Map<String, Map<String, Object>> _getObjectDetailsForImport(Map<String, String> idValues, ObjetsMaquetteApi objetsMaquetteApi, ArbresApi arbresApi, Logger logger) throws ApiException
278    {
279        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
280        Set<String> handledObjects = new HashSet<>();
281
282        for (String idValue : idValues.keySet())
283        {
284            // Get the program tree structure from the idValue
285            Arbre tree = arbresApi.lireArbre(_structureCode, idValue);
286            
287            // Get the program's details from the tree structure
288            String code = idValues.get(idValue);
289            ObjetMaquette programDetails = tree.getObjetsMaquette().get(code);
290            
291            if (programDetails != null)
292            {
293                List<ObjetMaquette> children = new ArrayList<>();
294                children.add(programDetails);
295                while (!children.isEmpty())
296                {
297                    ObjetMaquette child = children.remove(0);
298    
299                    String childCode = _createPegaseSyncCode(child);
300                    if (handledObjects.add(childCode))
301                    {
302                        // Get the current child's children
303                        Collection<Enfant> grandChildren = child.getEnfants().values();
304                        if (!grandChildren.isEmpty())
305                        {
306                            try 
307                            {
308                                // Children must be read from objets-maquette API because they do not keep the right order in the tree
309                                // Call the objets-maquette API only if the object has children (detected in the tree)
310                                grandChildren = objetsMaquetteApi.lireEnfants(_structureCode, child.getId());
311                            }
312                            catch (Exception e) 
313                            {
314                                // If the request doesn't work, the ObjetMaquette is a GROUPEMENT that we created
315                                // Do nothing to keep children that have been added in the right order
316                            }
317                        }
318                        
319                        Map<String, Collection<? extends Object>> childResult = _objectDetailsToMap(child, grandChildren, tree, logger);
320                        children.addAll((List<ObjetMaquette>) childResult.remove("childrenToSend"));
321    
322                        // Add mapped object to the results
323                        results.put((String) childResult.remove(getIdField()).iterator().next(), (Map<String, Object>) (Object) childResult);
324                    }
325                }
326            }
327        }
328        
329        return results;
330    }
331    
332    private String _createPegaseSyncCode(ObjetMaquette objectDetails) 
333    {
334        String code = objectDetails.getCode();
335        
336        if ("FORMATION".equals(objectDetails.getType().getLibelle()))
337        {
338            String version = objectDetails.getDetail().getFormation().getVersion().toString();
339            
340            code += "/" + version;
341        }
342        
343        return code;
344    }
345    
346    @Override
347    public List<ModifiableContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
348    {
349        Map<String, Object> parameters = putIdParameter(idValue);
350        return _importOrSynchronizeContents(parameters, true, logger);
351    }
352    
353    @Override
354    public void synchronizeContent(ModifiableContent content, Logger logger) throws Exception
355    {
356        String idValue = content.getValue(getIdField());
357        Map<String, Object> parameters = putIdParameter(idValue);
358        _importOrSynchronizeContents(parameters, true, logger);
359    }
360    
361    @Override
362    protected List<ModifiableContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger)
363    {
364        _importedContents = new HashMap<>();
365        _synchronizedContents = new HashSet<>();
366        _updatedRelationContents = new HashSet<>();
367        _contentsChildren = new HashMap<>();
368        
369        List<ModifiableContent> contents =  super._importOrSynchronizeContents(searchParams, forceImport, logger);
370        _updateRelations(logger);
371        _updateWorkflowStatus(logger);
372        
373        _importedContents = null;
374        _synchronizedContents = null;
375        _updatedRelationContents = null;
376        _contentsChildren = null;
377        
378        return contents;
379    }
380    
381    private void _updateRelations(Logger logger)
382    {
383        for (String contentId : _contentsChildren.keySet())
384        {
385            WorkflowAwareContent content = _resolver.resolveById(contentId);
386            Set<String> childrenCodes = _contentsChildren.get(contentId);
387            String contentLanguage = content.getLanguage();
388            String contentCatalog = content.getValue("catalog");
389            Map<String, Set<String>> childrenByAttributeName = new HashMap<>();
390            
391            for (String childCode : childrenCodes)
392            {
393                Expression expression = new AndExpression(
394                    _sccHelper.getCollectionExpression(getId()),
395                    new StringExpression(getIdField(), Operator.EQ, childCode),
396                    new StringExpression("catalog", Operator.EQ, contentCatalog),
397                    new LanguageExpression(Operator.EQ, contentLanguage)
398                );
399                
400                ModifiableContent childContent = _resolver.<ModifiableContent>query(ContentQueryHelper.getContentXPathQuery(expression))
401                    .stream()
402                    .findFirst()
403                    .orElse(null);
404                
405                if (childContent == null)
406                {
407                    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);
408                }
409                else
410                {
411                    String attributesName = _getChildAttributeName(content, childContent);
412                    if (attributesName != null)
413                    {
414                        Set<String> children = childrenByAttributeName.computeIfAbsent(attributesName, __ -> new LinkedHashSet<>());
415                        children.add(childContent.getId());
416                    }
417                    else
418                    {
419                        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]);
420                    }
421                }
422            }
423            _updateRelations(content, childrenByAttributeName, logger);
424        }
425    }
426    
427    private String _getChildAttributeName(Content parentContent, Content childContent)
428    {
429        if (childContent instanceof Course && parentContent instanceof CourseList)
430        {
431            return CourseList.CHILD_COURSES;
432        }
433        
434        if (parentContent instanceof Course && childContent instanceof CourseList)
435        {
436            return Course.CHILD_COURSE_LISTS;
437        }
438        
439        if (parentContent instanceof TraversableProgramPart && childContent instanceof ProgramPart)
440        {
441            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
442        }
443        
444        return null;
445    }
446    
447    private void _updateRelations(WorkflowAwareContent content, Map<String, Set<String>> contentRelationsByAttribute, Logger logger)
448    {
449        // Compute the view
450        View view = View.of(content.getModel(), contentRelationsByAttribute.keySet().toArray(new String[contentRelationsByAttribute.size()]));
451        
452        // Compute values
453        Map<String, Object> values = new HashMap<>();
454        for (String attributeName : contentRelationsByAttribute.keySet())
455        {
456            // Add the content relations to the existing ones
457            List<String> attributeValue = _getContentAttributeValue(content, attributeName);
458            contentRelationsByAttribute.get(attributeName)
459                .stream()
460                .filter(id -> !attributeValue.contains(id))
461                .forEach(attributeValue::add);
462            
463            values.put(attributeName, attributeValue.toArray(new String[attributeValue.size()]));
464        }
465        
466        // Compute not synchronized contents
467        Set<String> notSynchronizedContentIds = contentRelationsByAttribute.values()
468                .stream()
469                .flatMap(Collection::stream)
470                .filter(id -> !_synchronizedContents.contains(id))
471                .collect(Collectors.toSet());
472        
473        try
474        {
475            _editContent(content, Optional.of(view), values, Map.of(), false, notSynchronizedContentIds, logger);
476        }
477        catch (WorkflowException e)
478        {
479            _nbError++;
480            logger.error("The content '{}' cannot be links edited (workflow action)", content, e);
481        }
482    }
483    
484    private List<String> _getContentAttributeValue(Content content, String attributeName)
485    {
486        return Optional.of(attributeName)
487            .map(content::<ContentValue[]>getValue)
488            .map(Stream::of)
489            .orElseGet(Stream::empty)
490            .map(ContentValue::getContentId)
491            .collect(Collectors.toList());
492    }
493    
494    private void _updateWorkflowStatus(Logger logger)
495    {
496        // Validate contents -> only on newly imported contents
497        if (validateAfterImport())
498        {
499            for (String contentId : _importedContents.keySet())
500            {
501                WorkflowAwareContent content = _resolver.resolveById(contentId);
502                Integer validationActionId = _importedContents.get(contentId);
503                if (validationActionId > 0)
504                {
505                    validateContent(content, validationActionId, logger);
506                }
507            }
508        }
509    }
510    
511    /**
512     * Get the workflow description for the given category.
513     * @param category The category
514     * @return the matching workflow description
515     * @throws IllegalArgumentException if the given categorie does not match any content workflow
516     */
517    protected ContentWorkflowDescription _mapWorkflowDescription(String category) throws IllegalArgumentException
518    {
519        switch (category)
520        {
521            case "formation":
522                return ContentWorkflowDescription.PROGRAM_WF_DESCRIPTION;
523            case "OO":
524                return ContentWorkflowDescription.SUBPROGRAM_WF_DESCRIPTION;
525            case "OTT":
526                return ContentWorkflowDescription.CONTAINER_WF_DESCRIPTION;
527            case "GROUPEMENT":
528                return ContentWorkflowDescription.COURSELIST_WF_DESCRIPTION;
529            case "OP":
530                return ContentWorkflowDescription.COURSE_WF_DESCRIPTION;
531            default:
532                throw new IllegalArgumentException("The category '" + category + "' cannot be converted to an Ametys content type.");
533        }
534    }
535
536    @SuppressWarnings("unchecked")
537    @Override
538    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
539    {
540        return (Map<String, Map<String, List<Object>>>) (Object) internalSearch(parameters, 0, Integer.MAX_VALUE, null, logger);
541    }
542
543    @Override
544    protected void configureDataSource(Configuration configuration) throws ConfigurationException
545    {
546        _odfLang = Config.getInstance().getValue("odf.programs.lang");
547        _searchFields = new HashSet<>();
548        _mappingByContentType = new HashMap<>();
549        
550        if (Config.getInstance().getValue("pegase.activate", true, false))
551        {
552            _structureCode = Config.getInstance().getValue("pegase.structure.code");
553    
554            // Champs affichés dans la recherche
555            _searchFields.add("code");
556            _searchFields.add("libelleCourt");
557            _searchFields.add("libelleLong");
558            _searchFields.add("version");
559            _searchFields.add("id");
560            _searchFields.add("codeTypeDiplome");
561            _searchFields.add("codeNiveauFormation");
562            _searchFields.add("codeDomaineFormation");
563            _searchFields.add("codeNatureDiplome");
564            _searchFields.add("codeNiveauDiplome");
565            _searchFields.add("codeMention");
566            _searchFields.add("codeChampFormation");
567            _searchFields.add("ects");
568            
569            
570            // Program
571            Map<String, List<String>> contentTypeMapping = new HashMap<>();
572            contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt"));
573            contentTypeMapping.put("ects", List.of("ects"));
574            contentTypeMapping.put("educationKind", List.of("codeNatureDiplomeCode"));
575            contentTypeMapping.put("presentation", List.of("description"));
576            contentTypeMapping.put("mention", List.of("codeMention"));
577            contentTypeMapping.put("domain", List.of("codeDomaineFormation"));
578            contentTypeMapping.put("programField", List.of("codeChampFormation"));
579            contentTypeMapping.put("educationLevel", List.of("codeNiveauFormation"));
580            contentTypeMapping.put("rncpLevel", List.of("codeNiveauDiplome"));
581            contentTypeMapping.put("degree", List.of("codeTypeDiplome"));
582            _mappingByContentType.put(ProgramFactory.PROGRAM_CONTENT_TYPE, contentTypeMapping);
583            
584            // SubProgram
585            contentTypeMapping = new HashMap<>();
586            contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt"));
587            contentTypeMapping.put("ects", List.of("ects"));
588            contentTypeMapping.put("presentation", List.of("description"));
589            _mappingByContentType.put(SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, contentTypeMapping);
590    
591            // Container
592            contentTypeMapping = new HashMap<>();
593            contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt"));
594            contentTypeMapping.put("ects", List.of("ects"));
595            contentTypeMapping.put("description", List.of("description"));
596            contentTypeMapping.put("nature", List.of("type"));
597            _mappingByContentType.put(ContainerFactory.CONTAINER_CONTENT_TYPE, contentTypeMapping);
598            
599            // CourseList
600            contentTypeMapping = new HashMap<>();
601            contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt"));
602            contentTypeMapping.put("plageDeChoix", List.of("plageDeChoix"));
603            contentTypeMapping.put("obligatoire", List.of("obligatoire"));
604            contentTypeMapping.put("min", List.of("plageMin"));
605            contentTypeMapping.put("max", List.of("plageMax"));
606            _mappingByContentType.put(CourseListFactory.COURSE_LIST_CONTENT_TYPE, contentTypeMapping);
607    
608            // Course
609            contentTypeMapping = new HashMap<>();
610            contentTypeMapping.put("title", List.of("libelleLong", "libelleCourt"));
611            contentTypeMapping.put("ects", List.of("ects"));
612            contentTypeMapping.put("description", List.of("description"));
613            contentTypeMapping.put("courseType", List.of("type"));
614            _mappingByContentType.put(CourseFactory.COURSE_CONTENT_TYPE, contentTypeMapping);
615        }
616    }
617
618    @Override
619    protected void configureSearchModel()
620    {
621        List<String> sortableColumns = List.of("code", "libelleCourt", "version");
622
623        _searchModelConfiguration.addCriterion("libelle", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_LIBELLE"), "string");
624        _searchModelConfiguration.addCriterion("code", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_CODE"), "string");
625        _searchModelConfiguration.addCriterion("version", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VERSION"), "string");
626        _searchModelConfiguration.addCriterion("validee", new I18nizableText("plugin.odf-sync", "PLUGINS_ODF_SYNC_PEGASE_CRITERION_VALIDEE"), "boolean", "edition.boolean-combobox");
627
628        for (String keptField : _searchFields)
629        {
630            if (sortableColumns.contains(keptField))
631            {
632                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), true);
633            }
634            else
635            {
636                _searchModelConfiguration.addColumn(keptField, new I18nizableText(keptField), false);
637            }
638        }
639    }
640
641    /**
642     * Get the catalog for import.
643     * @return the catalog
644     */
645    protected String getCatalog()
646    {
647        return Optional.of(getParameterValues())
648                   .map(params -> params.get("catalog"))
649                   .map(String.class::cast)
650                   .filter(StringUtils::isNotBlank)
651                   .orElseGet(() -> _catalogsManager.getDefaultCatalogName());
652    }
653    
654    /**
655     * Get or create the content defined by the given parameters.
656     * @param lang The content language
657     * @param idValue The synchronization code
658     * @param remoteValues The remote values
659     * @param forceImport <code>true</code> to force import (only on single import or unlimited global synchronization)
660     * @param logger The logger
661     * @return the content
662     * @throws Exception if an error occurs
663     */
664    protected ModifiableContent _getOrCreateContent(String lang, String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger) throws Exception
665    {
666        ContentWorkflowDescription wfDescription = (ContentWorkflowDescription) remoteValues.get("workflowDescription").get(0);
667        ModifiableContent content = _getContent(lang, idValue, wfDescription.getContentType());
668        if (content == null && (forceImport || !synchronizeExistingContentsOnly()))
669        {
670            // Calculate contentTitle
671            String contentTitle = Optional.of(remoteValues)
672                .map(v -> v.get("title"))
673                .map(List::stream)
674                .orElseGet(Stream::empty)
675                .filter(String.class::isInstance)
676                .map(String.class::cast)
677                .filter(StringUtils::isNotEmpty)
678                .findFirst()
679                .orElse(idValue);
680            
681            String contentName = NameHelper.filterName(_contentPrefix + "-" + contentTitle + "-" + lang);
682            
683            Map<String, Object> inputs = new HashMap<>();
684            String catalog = getCatalog();
685            if (catalog != null)
686            {
687                inputs.put(AbstractCreateODFContentFunction.CONTENT_CATALOG_KEY, catalog);
688            }
689            
690            Map<String, Object> resultMap = _contentWorkflowHelper.createContent(
691                    wfDescription.getWorkflowName(),
692                    wfDescription.getInitialActionId(),
693                    contentName,
694                    contentTitle,
695                    new String[] {wfDescription.getContentType()},
696                    null,
697                    lang,
698                    null,
699                    null,
700                    inputs);
701            
702            if ((boolean) resultMap.getOrDefault("error", false))
703            {
704                _nbError++;
705            }
706            
707            content = (ModifiableContent) resultMap.get(Content.class.getName());
708
709            if (content != null)
710            {
711                _sccHelper.updateSCCProperty(content, getId());
712                
713                // Set sync code
714                content.setValue(getIdField(), idValue);
715                
716                content.saveChanges();
717                _importedContents.put(content.getId(), wfDescription.getValidationActionId());
718                _nbCreatedContents++;
719            }
720        }
721        return content;
722    }
723    
724    /**
725     * Get the content from the synchronization code, the lang, the catalog and the content type.
726     * @param lang The lang
727     * @param syncCode The synchronization code
728     * @param contentType The content type
729     * @return the retrieved content
730     */
731    protected ModifiableContent _getContent(String lang, String syncCode, String contentType)
732    {
733        List<Expression> expList = _getExpressionsList(lang, syncCode, contentType);
734        AndExpression andExp = new AndExpression(expList.toArray(new Expression[expList.size()]));
735        String xPathQuery = ContentQueryHelper.getContentXPathQuery(andExp);
736
737        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(xPathQuery);
738
739        if (contents.getSize() > 0)
740        {
741            return contents.iterator().next();
742        }
743        
744        return null;
745    }
746    
747    @Override
748    protected Optional<ModifiableContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
749    {
750        try
751        {
752            ModifiableContent content = _getOrCreateContent(lang, idValue, remoteValues, forceImport, logger);
753            if (content != null)
754            {
755                return Optional.of(_synchronizeContent(content, remoteValues, logger));
756            }
757        }
758        catch (Exception e)
759        {
760            _nbError++;
761            logger.error("An error occurred while importing or synchronizing content", e);
762        }
763        
764        return Optional.empty();
765    }
766    
767    @SuppressWarnings("unchecked")
768    @Override
769    protected ModifiableContent _synchronizeContent(ModifiableContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
770    {
771        super._synchronizeContent(content, remoteValues, logger);
772        // Add children to the list to handle later to add relations
773        if (remoteValues.containsKey("children"))
774        {
775            Set<String> children = _contentsChildren.computeIfAbsent(content.getId(), __ -> new LinkedHashSet<>());
776            children.addAll((List<String>) (Object) remoteValues.get("children"));
777        }
778        return content;
779    }
780    
781    @Override
782    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableContent content, Map<String, Object> additionalParameters, boolean create, Logger logger) throws Exception
783    {
784        _synchronizedContents.add(content.getId());
785        return super._fillContent(remoteValues, content, additionalParameters, create, logger);
786    }
787    
788    @Override
789    public List<String> getLanguages()
790    {
791        return List.of(_odfLang);
792    }
793
794    @Override
795    protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType)
796    {
797        List<Expression> expList = super._getExpressionsList(lang, idValue, contentType);
798        String catalog = getCatalog();
799        if (catalog != null)
800        {
801            expList.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
802        }
803        return expList;
804    }
805    
806    @Override
807    protected Map<String, Map<String, List<Object>>> getTransformedRemoteValues(Map<String, Object> searchParameters, Logger logger)
808    {
809        searchParameters.put("import", true);
810        return super.getTransformedRemoteValues(searchParameters, logger);
811    }
812    
813    @Override
814    protected Map<String, Object> _transformRemoteValuesCardinality(Map<String, List<Object>> remoteValues, String obsoleteContentTypeId)
815    {
816        String realContentTypeId = Optional.of(remoteValues)
817                .map(v -> v.get("workflowDescription"))
818                .map(l -> l.get(0))
819                .map(ContentWorkflowDescription.class::cast)
820                .map(ContentWorkflowDescription::getContentType)
821                .orElse(null);
822        return super._transformRemoteValuesCardinality(remoteValues, realContentTypeId);
823    }
824    
825    private PagedObjetMaquette _getPagedProgramsDetails(Map<String, Object> parameters, int offset, int limit, List<Object> sort, ObjetsMaquetteApi objetsMaquetteApi) throws ApiException
826    {
827        PagedObjetMaquette pagedPrograms = null;
828        Pageable pageable = new Pageable();  
829        int pageNumber = offset / 50;
830        pageable.setPage(pageNumber);
831        pageable.setTaille(limit);
832        
833        if (sort == null)
834        {
835            pageable.setTri(List.of());
836        }
837        else 
838        {
839            // Convert the sort parameter from Object to JSON
840            String jsonSortParameters = _jsonUtils.convertObjectToJson(sort.get(0));
841            // Convert the sort parameter from JSON to Map<String, Object>
842            Map<String, Object> sortParameters = _jsonUtils.convertJsonToMap(jsonSortParameters);
843            // Create the list containing the sort result; it is going to be of the form : ["field,direction"]
844            List<String> sortParametersArray = new ArrayList<>();
845            // Create the sort parameter that is going to be sent in the list; it is going to be of the form : "field,direction"
846            StringBuilder stringBuilder = new StringBuilder();
847            // Get the parameter "property" which is the column on which the sorting is meant to be made
848            String property = (String) sortParameters.get("property");
849            
850            if (!"code".equals(property) & !"libelle".equals(property) & !"version".equals(property))
851            {
852                if ("libelleCourt".equals(property))
853                {
854                    stringBuilder.append("libelle,").append((String) sortParameters.get("direction"));
855                    sortParametersArray.add(stringBuilder.toString());
856                }
857            }
858            else 
859            {
860                stringBuilder.append(property).append(",").append((String) sortParameters.get("direction"));
861                sortParametersArray.add(stringBuilder.toString());
862            }
863            
864            pageable.setTri(sortParametersArray);
865        }
866        
867        // Get the PagedObjetsMaquette : paged program with potential search parameters
868        Object versionO = parameters.get("version");
869        BigDecimal version = null;
870        
871        if (versionO != null)
872        {
873            version = new BigDecimal(versionO.toString());
874        }
875        
876        String status = null;
877        Object value = parameters.get("validee");
878        if (value != null)
879        {
880            status = (boolean) parameters.get("validee") ? "VALIDE" : "EN-CONSTRUCTION";
881        }
882        
883        pagedPrograms = objetsMaquetteApi.lireListeObjetsMaquette(_structureCode, "FORMATION", null, null, _getParametersInRegularExp("code", parameters), _getParametersInRegularExp("libelle", parameters), null, null, null, null, null, version, status, null, null, null, null, null, pageable);
884        return pagedPrograms;
885    }
886    
887    private String _getParametersInRegularExp(String field, Map<String, Object> parameters)
888    {
889        Object value = parameters.get(field);
890        if (value != null)
891        {
892            StringBuilder criteria = new StringBuilder();
893            criteria.append("*").append(value.toString()).append("*");
894            return criteria.toString();
895        }
896        
897        return null;
898    }
899    
900    private Map<String, List<? extends Object>> _getObjectFields(ObjetMaquette objectDetails, String categorie)
901    {
902        Map<String, List<? extends Object>> result = new HashMap<>();
903
904        ContentWorkflowDescription wfDescription = _mapWorkflowDescription(categorie);
905        result.put("workflowDescription", List.of(wfDescription));
906        
907        String contentTypeId = wfDescription.getContentType();
908        Map<String, List<String>> contentTypeMapping = _mappingByContentType.get(contentTypeId);
909        
910        Map<String, String> fields = _getFields(objectDetails);
911        
912        for (String attribute : contentTypeMapping.keySet())
913        {
914            List<String> jsonKeys = contentTypeMapping.get(attribute);
915            List<Object> values = new ArrayList<>();
916            
917            for (String key : jsonKeys)
918            {
919                String value = fields.get(key);
920                
921                if (value != null && !value.isEmpty() && !value.isBlank())
922                {
923                    values.add(value);
924                }
925                
926            }
927            
928            result.put(attribute, values); // Add the retrieved metadata values list to the contentResult
929        }
930        
931        // Add the ID field
932        String syncCode = _createPegaseSyncCode(objectDetails); 
933        if (syncCode != null)
934        {
935            result.put(getIdField(), List.of(syncCode));
936        }
937        
938        return result;
939    }
940    
941    private Map<String, Collection<? extends Object>> _objectDetailsToMap(ObjetMaquette objectDetails, Collection<Enfant> children, Arbre tree, Logger logger) 
942    {
943        Map<String, Collection<? extends Object>> result = new HashMap<>();
944
945        // Compute category depending on child - to create a Container if a GROUPEMENT has no child of type OP
946        String category = _getObjectCategory(objectDetails, children, tree);
947        
948        // Fill map with fields of the current object
949        result.putAll(_getObjectFields(objectDetails, category));
950        
951        // Compute children depending the current object
952        Collection<ObjetMaquette> childrenDetails = "GROUPEMENT".equals(category)
953                ? _filterGroupChildren(objectDetails, children, tree, logger) // Keep only OP under GROUPEMENT - ignore other objects
954                : _getObjectChildren(objectDetails, children, tree);          // Insert a GROUPEMENT if there are OP
955        
956        // Fill map with computed children
957        result.put("childrenToSend", childrenDetails);
958        result.put("children", childrenDetails.stream()
959                                             .map(ObjetMaquette::getCode)
960                                             .collect(Collectors.toList()));
961        
962        return result;
963    }
964    
965    private String _getObjectCategory(ObjetMaquette objectDetails, Collection<Enfant> children, Arbre tree)
966    {
967        String category = objectDetails.getCategorie();
968        if ("GROUPEMENT".equals(category) && children.stream()
969                                                     .map(child -> _getChildObjectDetails(child, tree))
970                                                     .map(ObjetMaquette::getCategorie)
971                                                     .noneMatch("OP"::equals))
972        {
973            // If there is no OP under the GROUPEMENT, we create a container (OTT) rather than a courseList (GROUPEMENT)
974            category = "OTT";
975        }
976        
977        return category;
978    }
979    
980    private Collection<ObjetMaquette> _filterGroupChildren(ObjetMaquette group, Collection<Enfant> children, Arbre tree, Logger logger)
981    {
982        Collection<ObjetMaquette> filteredChildren = new ArrayList<>();
983        
984        for (Enfant child : children)
985        {
986            ObjetMaquette childDetails = _getChildObjectDetails(child, tree);
987            if ("OP".equals(childDetails.getCategorie()))
988            {
989                filteredChildren.add(childDetails); 
990            }
991            else
992            {
993                // A GROUPEMENT can only have OP children to match courseList content type with courses children
994                ContentWorkflowDescription childWorkflowDescription = _mapWorkflowDescription(childDetails.getCategorie());
995                String childType = childWorkflowDescription.getContentType();
996                String courseListType = CourseListFactory.COURSE_LIST_CONTENT_TYPE;
997                logger.warn("The child content '{}' of type '{}' is not compatible with parent content '{}' of type '{}'.", childDetails.getCode(), childType, group.getCode(), courseListType);
998            }
999        }
1000        
1001        return filteredChildren;
1002    }
1003    
1004    private Collection<ObjetMaquette> _getObjectChildren(ObjetMaquette objetDetails, Collection<Enfant> children, Arbre tree)
1005    {
1006        Collection<ObjetMaquette> childrenDetails = new ArrayList<>();
1007        Map<String, Enfant> courses = new LinkedHashMap<>();
1008        for (Enfant child : children)
1009        {
1010            ObjetMaquette childDetails = _getChildObjectDetails(child, tree);
1011            if ("OP".equals(childDetails.getCategorie()))
1012            {
1013                // Put OP aside to add them as children of the inserted GROUPEMENT
1014                courses.put(childDetails.getCode(), child);
1015            }
1016            else
1017            {
1018                // Keep other children
1019                childrenDetails.add(childDetails);
1020            }
1021        }
1022        
1023        if (!courses.isEmpty())
1024        {
1025            // Create a GROUPEMENT Object
1026            ObjetMaquette childGroup = new ObjetMaquette();
1027            
1028            String code = _generateInsertedGroupementUniqueCode(objetDetails);
1029            childGroup.setCode(code);
1030            childGroup.setLibelle(code);
1031            childGroup.setLibelleLong(code);
1032            childGroup.setId(code);
1033            
1034            ObjetLibelle type = new ObjetLibelle();
1035            type.setCode("GROUPEMENT");
1036            
1037            childGroup.setType(type);
1038            childGroup.setCategorie("GROUPEMENT");
1039            
1040            // Add courses that has been put aside as children of the new GROUPEMENT
1041            childGroup.setEnfants(courses);
1042            
1043            // Add the GROUPEMENT to the current object children
1044            childrenDetails.add(childGroup);
1045        }
1046        
1047        return childrenDetails;
1048    }
1049    
1050    private String _generateInsertedGroupementUniqueCode(ObjetMaquette parent)
1051    {
1052        int maxPrefixWidth = __PEGASE_SYNC_CODE_MAX_LENGTH - __INSERTED_GROUPEMENT_SUFFIX.length();
1053        String codePrefix = parent.getCode();
1054
1055        if (codePrefix.length() > maxPrefixWidth)
1056        {
1057            // Create a hashCode of the prefix. Hash code is an integer that will take less than 20 characters
1058            int parentHashedCode = codePrefix.hashCode();
1059            codePrefix = Integer.toHexString(parentHashedCode).toUpperCase();
1060        }
1061
1062        return codePrefix + __INSERTED_GROUPEMENT_SUFFIX;
1063    }
1064    
1065    private ObjetMaquette _getChildObjectDetails(Enfant child, Arbre tree)
1066    {
1067        Ref enfantRef = child.getRef();
1068        String code = enfantRef.getCode();
1069        return tree.getObjetsMaquette().get(code);
1070    }
1071    
1072    private Map<String, String> _getFields(ObjetMaquette objectDetails)
1073    {
1074        ObjetMaquetteDetails detail = objectDetails.getDetail();
1075        Map<String, String> basicFields = Map.of(
1076                "id", StringUtils.defaultString(objectDetails.getId()),
1077                "code",  StringUtils.defaultString(objectDetails.getCode()),
1078                "libelleCourt", StringUtils.defaultString(objectDetails.getLibelle()),
1079                "libelleLong", StringUtils.defaultString(objectDetails.getLibelleLong()),
1080                "description", StringUtils.defaultString(objectDetails.getDescription()),
1081                "ects", Optional.ofNullable(objectDetails.getEcts())
1082                                            .map(value -> value.toString())
1083                                            .orElse(StringUtils.EMPTY),
1084                "type", Optional.ofNullable(objectDetails.getType())         // Get the ObjetLibelle nature
1085                        .map(objLibelleNature -> objLibelleNature.getCode()) // If the ObjetLibelle is not null, get the code
1086                        .orElse(StringUtils.EMPTY)                           // Else empty string
1087                );
1088        
1089        Map<String, String> allFields = new HashMap<>(basicFields);
1090        
1091        if (detail != null) 
1092        {
1093            String choices = Optional.ofNullable(objectDetails.getPlageDeChoix())
1094                                     .map(Object::toString)
1095                                     .orElse(StringUtils.EMPTY);
1096
1097            ObjetMaquetteGroupement group = detail.getGroupement();
1098            if (group != null && choices.isEmpty())
1099            {
1100                choices = Optional.ofNullable(group.getPlageDeChoix())
1101                                  .map(Object::toString)
1102                                  .orElse(StringUtils.EMPTY);
1103            }
1104            
1105            allFields.put("plageDeChoix", choices);
1106            
1107            ObjetMaquetteFormation program = detail.getFormation();
1108            if (program != null)
1109            {
1110                Map<String, String> programFields = Map.of(
1111                        "codeTypeDiplome", StringUtils.defaultString(program.getTypeDiplome()),
1112                        "codeNiveauFormation", StringUtils.defaultString(program.getNiveauFormation()),
1113                        "codeDomaineFormation", StringUtils.defaultString(program.getDomaineFormation()),
1114                        "codeNiveauDiplome", StringUtils.defaultString(program.getNiveauDiplome()),
1115                        "codeMention", StringUtils.defaultString(program.getMention()),
1116                        "codeChampFormation", StringUtils.defaultString(program.getChampFormation()),
1117                        "version", Optional.ofNullable(program.getVersion())
1118                                           .map(Object::toString)
1119                                           .orElse(StringUtils.EMPTY),
1120                        "codeNatureDiplome", Optional.ofNullable(program.getTypeFormation())
1121                                                     .map(ObjetMaquetteFormationTypeFormation::getLibelle)
1122                                                     .orElse(StringUtils.EMPTY),
1123                        "codeNatureDiplomeCode", Optional.ofNullable(program.getTypeFormation())
1124                                                         .map(ObjetMaquetteFormationTypeFormation::getCode)
1125                                                         .orElse(StringUtils.EMPTY)
1126                        );
1127                
1128                allFields.putAll(programFields);
1129            }
1130        }
1131        
1132        return allFields;
1133    }
1134}