001/*
002 *  Copyright 2017 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.odf;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.function.Function;
031import java.util.function.Predicate;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.activity.Initializable;
036import org.apache.avalon.framework.component.Component;
037import org.apache.avalon.framework.context.Context;
038import org.apache.avalon.framework.context.ContextException;
039import org.apache.avalon.framework.context.Contextualizable;
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.avalon.framework.service.Serviceable;
043import org.apache.cocoon.components.ContextHelper;
044import org.apache.cocoon.environment.Request;
045import org.apache.commons.lang3.ArrayUtils;
046import org.apache.commons.lang3.StringUtils;
047import org.apache.commons.lang3.Strings;
048import org.apache.commons.lang3.tuple.Pair;
049
050import org.ametys.cms.CmsConstants;
051import org.ametys.cms.content.ContentHelper;
052import org.ametys.cms.content.references.OutgoingReferences;
053import org.ametys.cms.content.references.OutgoingReferencesExtractor;
054import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
055import org.ametys.cms.data.ContentDataHelper;
056import org.ametys.cms.repository.Content;
057import org.ametys.cms.repository.ContentDAO;
058import org.ametys.cms.repository.ContentQueryHelper;
059import org.ametys.cms.repository.ContentTypeExpression;
060import org.ametys.cms.repository.DefaultContent;
061import org.ametys.cms.repository.LanguageExpression;
062import org.ametys.cms.repository.ModifiableContent;
063import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
064import org.ametys.cms.rights.ContentRightAssignmentContext;
065import org.ametys.core.cache.AbstractCacheManager;
066import org.ametys.core.cache.Cache;
067import org.ametys.core.right.RightManager;
068import org.ametys.core.ui.Callable;
069import org.ametys.odf.content.code.UniqueCodeGenerator;
070import org.ametys.odf.content.code.UniqueCodeGeneratorExtensionPoint;
071import org.ametys.odf.course.Course;
072import org.ametys.odf.course.CourseContainer;
073import org.ametys.odf.course.ShareableCourseHelper;
074import org.ametys.odf.courselist.CourseList;
075import org.ametys.odf.courselist.CourseList.ChoiceType;
076import org.ametys.odf.courselist.CourseListContainer;
077import org.ametys.odf.coursepart.CoursePart;
078import org.ametys.odf.coursepart.CoursePartFactory;
079import org.ametys.odf.data.EducationalPath;
080import org.ametys.odf.data.type.EducationalPathElementType;
081import org.ametys.odf.enumeration.OdfReferenceTableEntry;
082import org.ametys.odf.enumeration.OdfReferenceTableHelper;
083import org.ametys.odf.orgunit.OrgUnit;
084import org.ametys.odf.orgunit.OrgUnitFactory;
085import org.ametys.odf.orgunit.RootOrgUnitProvider;
086import org.ametys.odf.person.Person;
087import org.ametys.odf.program.AbstractProgram;
088import org.ametys.odf.program.AbstractTraversableProgramPart;
089import org.ametys.odf.program.Container;
090import org.ametys.odf.program.Program;
091import org.ametys.odf.program.ProgramFactory;
092import org.ametys.odf.program.ProgramPart;
093import org.ametys.odf.program.SubProgram;
094import org.ametys.odf.program.TraversableProgramPart;
095import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
096import org.ametys.plugins.repository.AmetysObject;
097import org.ametys.plugins.repository.AmetysObjectIterable;
098import org.ametys.plugins.repository.AmetysObjectIterator;
099import org.ametys.plugins.repository.AmetysObjectResolver;
100import org.ametys.plugins.repository.AmetysRepositoryException;
101import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
102import org.ametys.plugins.repository.RepositoryConstants;
103import org.ametys.plugins.repository.UnknownAmetysObjectException;
104import org.ametys.plugins.repository.collection.AmetysObjectCollection;
105import org.ametys.plugins.repository.data.holder.group.Repeater;
106import org.ametys.plugins.repository.data.holder.group.RepeaterEntry;
107import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
108import org.ametys.plugins.repository.jcr.DefaultAmetysObject;
109import org.ametys.plugins.repository.model.RepeaterDefinition;
110import org.ametys.plugins.repository.model.RepositoryDataContext;
111import org.ametys.plugins.repository.query.QueryHelper;
112import org.ametys.plugins.repository.query.SortCriteria;
113import org.ametys.plugins.repository.query.expression.AndExpression;
114import org.ametys.plugins.repository.query.expression.Expression;
115import org.ametys.plugins.repository.query.expression.Expression.Operator;
116import org.ametys.plugins.repository.query.expression.OrExpression;
117import org.ametys.plugins.repository.query.expression.StringExpression;
118import org.ametys.runtime.config.Config;
119import org.ametys.runtime.i18n.I18nizableText;
120import org.ametys.runtime.model.ModelHelper;
121import org.ametys.runtime.model.ModelItem;
122import org.ametys.runtime.model.type.DataContext;
123import org.ametys.runtime.plugin.component.AbstractLogEnabled;
124
125import com.opensymphony.workflow.WorkflowException;
126
127/**
128 * Helper for ODF contents
129 *
130 */
131public class ODFHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
132{
133    /** The component role. */
134    public static final String ROLE = ODFHelper.class.getName();
135    
136    /** Request attribute to get the "Live" version of contents */
137    public static final String REQUEST_ATTRIBUTE_VALID_LABEL = "live-version";
138    
139    /** The default id of initial workflow action */
140    protected static final int __INITIAL_WORKFLOW_ACTION_ID = 0;
141    
142    private static final String __ANCESTORS_CACHE = ODFHelper.class.getName() + "$ancestors";
143    
144    private static final String __PARENT_PROGRAM_ITEMS_CACHE = ODFHelper.class.getName() + "$parentProgramItems";
145    
146    private static final String __ODF_PLUGIN_NAME = "odf";
147    
148    /** Ametys object resolver */
149    protected AmetysObjectResolver _resolver;
150    /** The content types manager */
151    protected ContentTypeExtensionPoint _cTypeEP;
152    /** Root orgunit */
153    protected RootOrgUnitProvider _ouRootProvider;
154    /** Helper for shareable course */
155    protected ShareableCourseHelper _shareableCourseHelper;
156    /** The Avalon context */
157    protected Context _context;
158    /** The cache manager */
159    protected AbstractCacheManager _cacheManager;
160    /** The content helper */
161    protected ContentHelper _contentHelper;
162    /** The outgoing references extractor */
163    protected OutgoingReferencesExtractor _outgoingReferencesExtractor;
164    /** The content DAO **/
165    protected ContentDAO _contentDAO;
166    /** The helper for reference tables from ODF */
167    protected OdfReferenceTableHelper _refTableHelper;
168    /** The right manager */
169    protected RightManager _rightManager;
170    
171    private UniqueCodeGeneratorExtensionPoint _codeGeneratorEP;
172    private Optional<String> _yearId = Optional.empty();
173
174    
175    public void contextualize(Context context) throws ContextException
176    {
177        _context = context;
178    }
179    
180    @Override
181    public void service(ServiceManager manager) throws ServiceException
182    {
183        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
184        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
185        _ouRootProvider = (RootOrgUnitProvider) manager.lookup(RootOrgUnitProvider.ROLE);
186        _shareableCourseHelper = (ShareableCourseHelper) manager.lookup(ShareableCourseHelper.ROLE);
187        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
188        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
189        _outgoingReferencesExtractor = (OutgoingReferencesExtractor) manager.lookup(OutgoingReferencesExtractor.ROLE);
190        _contentDAO = (ContentDAO) manager.lookup(ContentDAO.ROLE);
191        _codeGeneratorEP = (UniqueCodeGeneratorExtensionPoint) manager.lookup(UniqueCodeGeneratorExtensionPoint.ROLE);
192        _refTableHelper = (OdfReferenceTableHelper) manager.lookup(OdfReferenceTableHelper.ROLE);
193        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
194    }
195    
196    public void initialize() throws Exception
197    {
198        _cacheManager.createRequestCache(__ANCESTORS_CACHE,
199                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_ANCESTORS_LABEL"),
200                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_ANCESTORS_DESCRIPTION"),
201                false);
202        
203        _cacheManager.createRequestCache(__PARENT_PROGRAM_ITEMS_CACHE,
204                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_PARENT_PROGRAM_ITEMS_LABEL"),
205                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_ODF_PARENT_PROGRAM_ITEMS_DESCRIPTION"),
206                false);
207    }
208    
209    /**
210     * Determines if legacy sax of program structure should be used
211     * @param programItem the program item beeing rendering
212     * @return true if legacy sax of program structure should be used
213     */
214    public boolean useLegacyProgramStructure(ProgramItem programItem)
215    {
216        return true;
217    }
218    
219    /**
220     * Generate an unique code for a given ODF content type
221     * @param cTypeId The id of ODF content type
222     * @return The unique code
223     */
224    public synchronized String generateUniqueCode(String cTypeId)
225    {
226        String codeGenId = Config.getInstance().getValue("odf.code.generator", true, "odf-code-incremental");
227        UniqueCodeGenerator codeGen = _codeGeneratorEP.getExtension(codeGenId);
228        
229        return codeGen.generateUniqueCode(cTypeId);
230    }
231    
232    /**
233     * Gets the root for ODF contents
234     * @return the root for ODF contents
235     */
236    public AmetysObjectCollection getRootContent()
237    {
238        return getRootContent(false);
239    }
240    
241    /**
242     * Gets the root for ODF contents
243     * @param create <code>true</code> to create automatically the root when missing.
244     * @return the root for ODF contents
245     */
246    public AmetysObjectCollection getRootContent(boolean create)
247    {
248        ModifiableTraversableAmetysObject pluginsNode = _resolver.resolveByPath("/ametys:plugins/");
249        
250        boolean needSave = false;
251        if (!pluginsNode.hasChild(__ODF_PLUGIN_NAME))
252        {
253            if (create)
254            {
255                pluginsNode.createChild(__ODF_PLUGIN_NAME, "ametys:unstructured");
256                needSave = true;
257            }
258            else
259            {
260                throw new UnknownAmetysObjectException("Node '/ametys:plugins/" + __ODF_PLUGIN_NAME + "' is missing");
261            }
262        }
263        
264        ModifiableTraversableAmetysObject pluginNode = pluginsNode.getChild(__ODF_PLUGIN_NAME);
265        if (!pluginNode.hasChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents"))
266        {
267            if (create)
268            {
269                pluginNode.createChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents", "ametys:collection");
270                needSave = true;
271            }
272            else
273            {
274                throw new UnknownAmetysObjectException("Node '/ametys:plugins/" + __ODF_PLUGIN_NAME + "/ametys:contents' is missing");
275            }
276        }
277        
278        if (needSave)
279        {
280            pluginsNode.saveChanges();
281        }
282        
283        return pluginNode.getChild(RepositoryConstants.NAMESPACE_PREFIX + ":contents");
284    }
285    
286    /**
287     * Get the {@link ProgramItem}s matching the given arguments
288     * @param cTypeId The id of content type. Can be null to get program's items whatever their content type.
289     * @param code The code. Can be null to get program's items regardless of their code
290     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
291     * @param lang The search language. Can be null to get program's items regardless of their language
292     * @param <C> The content return type
293     * @return The matching program items
294     */
295    public <C extends Content> AmetysObjectIterable<C> getProgramItems(String cTypeId, String code, String catalogName, String lang)
296    {
297        return getProgramItems(cTypeId, code, catalogName, lang, null, null);
298    }
299    
300    /**
301     * Get the {@link ProgramItem}s matching the given arguments
302     * @param cTypeIds The id of content types. Can be empty to get program's items whatever their content type.
303     * @param code The code. Can be null to get program's items regardless of their code
304     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
305     * @param lang The search language. Can be null to get program's items regardless of their language
306     * @param <C> The content return type
307     * @return The matching program items
308     */
309    public <C extends Content> AmetysObjectIterable<C> getProgramItems(Collection<String> cTypeIds, String code, String catalogName, String lang)
310    {
311        return getProgramItems(cTypeIds, code, catalogName, lang, null, null);
312    }
313    
314    /**
315     * Get the {@link ProgramItem}s matching the given arguments
316     * @param cTypeId The id of content type. Can be null to get program's items whatever their content type.
317     * @param code The code. Can be null to get program's items regardless of their code
318     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
319     * @param lang The search language. Can be null to get program's items regardless of their language
320     * @param additionnalExpr An additional expression for filtering result. Can be null
321     * @param sortCriteria criteria for sorting results
322     * @param <C> The content return type
323     * @return The matching program items
324     */
325    public <C extends Content> AmetysObjectIterable<C> getProgramItems(String cTypeId, String code, String catalogName, String lang, Expression additionnalExpr, SortCriteria sortCriteria)
326    {
327        return getProgramItems(cTypeId != null ? Collections.singletonList(cTypeId) : Collections.EMPTY_LIST, code, catalogName, lang, additionnalExpr, sortCriteria);
328    }
329    
330    /**
331     * Get the {@link ProgramItem}s matching the given arguments
332     * @param cTypeIds The id of content types. Can be empty to get program's items whatever their content type.
333     * @param code The code. Can be null to get program's items regardless of their code
334     * @param catalogName The search catalog. Can be null to get program's items regardless the catalog they belong to.
335     * @param lang The search language. Can be null to get program's items regardless of their language
336     * @param additionnalExpr An additional expression for filtering result. Can be null
337     * @param sortCriteria criteria for sorting results
338     * @param <C> The content return type
339     * @return The matching program items
340     */
341    public <C extends Content> AmetysObjectIterable<C> getProgramItems(Collection<String> cTypeIds, String code, String catalogName, String lang, Expression additionnalExpr, SortCriteria sortCriteria)
342    {
343        List<Expression> exprs = new ArrayList<>();
344        
345        if (!cTypeIds.isEmpty())
346        {
347            exprs.add(new ContentTypeExpression(Operator.EQ, cTypeIds.toArray(new String[cTypeIds.size()])));
348        }
349        if (StringUtils.isNotEmpty(code))
350        {
351            exprs.add(new StringExpression(ProgramItem.CODE, Operator.EQ, code));
352        }
353        if (StringUtils.isNotEmpty(catalogName))
354        {
355            exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalogName));
356        }
357        if (StringUtils.isNotEmpty(lang))
358        {
359            exprs.add(new LanguageExpression(Operator.EQ, lang));
360        }
361        if (additionnalExpr != null)
362        {
363            exprs.add(additionnalExpr);
364        }
365        
366        Expression expr = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
367        
368        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr, sortCriteria);
369        return _resolver.query(xpathQuery);
370    }
371    
372    /**
373     * Get the equivalent {@link CoursePart} of the source {@link CoursePart} in given catalog and language
374     * @param srcCoursePart The source course part
375     * @param catalogName The name of catalog to search into
376     * @param lang The search language
377     * @return The equivalent program item or <code>null</code> if not exists
378     */
379    public CoursePart getCoursePart(CoursePart srcCoursePart, String catalogName, String lang)
380    {
381        return getODFContent(CoursePartFactory.COURSE_PART_CONTENT_TYPE, srcCoursePart.getCode(), catalogName, lang);
382    }
383    
384    /**
385     * Get the equivalent {@link ProgramItem} of the source {@link ProgramItem} in given catalog and language
386     * @param <T> The type of returned object, it have to be a subclass of {@link ProgramItem}
387     * @param srcProgramItem The source program item
388     * @param catalogName The name of catalog to search into
389     * @param lang The search language
390     * @return The equivalent program item or <code>null</code> if not exists
391     */
392    public <T extends ProgramItem> T getProgramItem(T srcProgramItem, String catalogName, String lang)
393    {
394        return getODFContent(((Content) srcProgramItem).getTypes()[0], srcProgramItem.getCode(), catalogName, lang);
395    }
396    
397    /**
398     * Get the equivalent {@link Content} having the same code in given catalog and language
399     * @param <T> The type of returned object, it have to be a subclass of {@link AmetysObject}
400     * @param contentType The content type to search for
401     * @param odfContentCode The code of the ODF content
402     * @param catalogName The name of catalog to search into
403     * @param lang The search language
404     * @return The equivalent content or <code>null</code> if not exists
405     */
406    public <T extends AmetysObject> T getODFContent(String contentType, String odfContentCode, String catalogName, String lang)
407    {
408        Expression contentTypeExpr = new ContentTypeExpression(Operator.EQ, contentType);
409        Expression langExpr = new LanguageExpression(Operator.EQ, lang);
410        Expression catalogExpr = new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalogName);
411        Expression codeExpr = new StringExpression(ProgramItem.CODE, Operator.EQ, odfContentCode);
412        
413        Expression expr = new AndExpression(contentTypeExpr, langExpr, catalogExpr, codeExpr);
414        
415        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr);
416        AmetysObjectIterable<T> contents = _resolver.query(xpathQuery);
417        AmetysObjectIterator<T> contentsIt = contents.iterator();
418        if (contentsIt.hasNext())
419        {
420            return contentsIt.next();
421        }
422        
423        return null;
424    }
425    
426    /**
427     * Get the child program items of a {@link ProgramItem}
428     * @param programItem The program item
429     * @return The child program items
430     */
431    public List<ProgramItem> getChildProgramItems(ProgramItem programItem)
432    {
433        List<ProgramItem> children = new ArrayList<>();
434        
435        if (programItem instanceof TraversableProgramPart programPart)
436        {
437            children.addAll(programPart.getProgramPartChildren());
438        }
439        
440        if (programItem instanceof CourseContainer courseContainer)
441        {
442            children.addAll(courseContainer.getCourses());
443        }
444        
445        if (programItem instanceof Course course)
446        {
447            children.addAll(course.getCourseLists());
448        }
449        
450        return children;
451    }
452
453    /**
454     * Get the child subprograms of a {@link ProgramPart}
455     * @param programPart The program part
456     * @return The child subprograms
457     */
458    public Set<SubProgram> getChildSubPrograms(ProgramPart programPart)
459    {
460        Set<SubProgram> subPrograms = new HashSet<>();
461        
462        if (programPart instanceof TraversableProgramPart traversableProgram)
463        {
464            if (programPart instanceof SubProgram subProgram)
465            {
466                subPrograms.add(subProgram);
467            }
468            traversableProgram.getProgramPartChildren().forEach(child -> subPrograms.addAll(getChildSubPrograms(child)));
469        }
470
471        return subPrograms;
472    }
473    
474    /**
475     * Gets (recursively) parent containers of this program item.
476     * @param programItem The program item
477     * @return parent containers of this program item.
478     */
479    public Set<Container> getParentContainers(ProgramItem programItem)
480    {
481        return getParentContainers(programItem, false);
482    }
483    
484    /**
485     * Gets (recursively) parent containers of this program item.
486     * @param programItem The program item
487     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
488     * @return parent containers of this program item.
489     */
490    public Set<Container> getParentContainers(ProgramItem programItem, boolean continueIfFound)
491    {
492        return _getParentsOfType(programItem, Container.class, continueIfFound);
493    }
494
495    /**
496     * Gets (recursively) parent programs of this course part.
497     * @param coursePart The course part
498     * @return parent programs of this course part.
499     */
500    public Set<Program> getParentPrograms(CoursePart coursePart)
501    {
502        Set<Program> programs = new HashSet<>();
503        for (Course course : coursePart.getCourses())
504        {
505            programs.addAll(getParentPrograms(course));
506        }
507        return programs;
508    }
509    
510    /**
511     * Gets (recursively) parent programs of this program item.
512     * @param programItem The program item
513     * @return parent programs of this program item.
514     */
515    public Set<Program> getParentPrograms(ProgramItem programItem)
516    {
517        return getParentPrograms(programItem, false);
518    }
519    
520    /**
521     * Gets (recursively) parent programs of this program item.
522     * @param programItem The program item
523     * @param includingItself <code>true</code> to return the program item if it's a {@link Program}.
524     * @return parent programs of this program item.
525     */
526    public Set<Program> getParentPrograms(ProgramItem programItem, boolean includingItself)
527    {
528        if (includingItself && programItem instanceof Program program)
529        {
530            return Set.of(program);
531        }
532        
533        return _getParentsOfType(programItem, Program.class, false);
534    }
535
536    /**
537     * Gets (recursively) parent subprograms of this course part.
538     * @param coursePart The course part
539     * @return parent subprograms of this course part.
540     */
541    public Set<SubProgram> getParentSubPrograms(CoursePart coursePart)
542    {
543        return getParentSubPrograms(coursePart, false);
544    }
545
546    /**
547     * Gets (recursively) parent subprograms of this course part.
548     * @param coursePart The course part
549     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
550     * @return parent subprograms of this course part.
551     */
552    public Set<SubProgram> getParentSubPrograms(CoursePart coursePart, boolean continueIfFound)
553    {
554        Set<SubProgram> abstractPrograms = new HashSet<>();
555        for (Course course : coursePart.getCourses())
556        {
557            abstractPrograms.addAll(getParentSubPrograms(course, continueIfFound));
558        }
559        return abstractPrograms;
560    }
561    
562    /**
563     * Gets (recursively) parent subprograms of this program item.
564     * @param programItem The program item
565     * @return parent subprograms of this program item.
566     */
567    public Set<SubProgram> getParentSubPrograms(ProgramItem programItem)
568    {
569        return getParentSubPrograms(programItem, false);
570    }
571    
572    /**
573     * Gets (recursively) parent subprograms of this program item.
574     * @param programItem The program item
575     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
576     * @return parent subprograms of this program item.
577     */
578    public Set<SubProgram> getParentSubPrograms(ProgramItem programItem, boolean continueIfFound)
579    {
580        return _getParentsOfType(programItem, SubProgram.class, continueIfFound);
581    }
582
583    /**
584     * Gets (recursively) parent abstract programs of this course part.
585     * @param coursePart The course part
586     * @return parent abstract programs of this course part.
587     */
588    public Set<AbstractProgram> getParentAbstractPrograms(CoursePart coursePart)
589    {
590        return getParentAbstractPrograms(coursePart, false);
591    }
592
593    /**
594     * Gets (recursively) parent abstract programs of this course part.
595     * @param coursePart The course part
596     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
597     * @return parent abstract programs of this course part.
598     */
599    public Set<AbstractProgram> getParentAbstractPrograms(CoursePart coursePart, boolean continueIfFound)
600    {
601        Set<AbstractProgram> abstractPrograms = new HashSet<>();
602        for (Course course : coursePart.getCourses())
603        {
604            abstractPrograms.addAll(getParentAbstractPrograms(course, continueIfFound));
605        }
606        return abstractPrograms;
607    }
608    
609    /**
610     * Gets (recursively) parent abstract programs of this program item.
611     * @param programItem The program item
612     * @return parent abstract programs of this program item.
613     */
614    public Set<AbstractProgram> getParentAbstractPrograms(ProgramItem programItem)
615    {
616        return getParentAbstractPrograms(programItem, false);
617    }
618    
619    /**
620     * Gets (recursively) parent abstract programs of this program item.
621     * @param programItem The program item
622     * @param continueIfFound If <code>true</code> continue searching corresponding parents in the parent structure, otherwise only closest items are returned.
623     * @return parent abstract programs of this program item.
624     */
625    public Set<AbstractProgram> getParentAbstractPrograms(ProgramItem programItem, boolean continueIfFound)
626    {
627        return _getParentsOfType(programItem, AbstractProgram.class, continueIfFound);
628    }
629
630    private <T> Set<T> _getParentsOfType(ProgramItem programItem, Class<T> classToTest, boolean continueIfFound)
631    {
632        Set<ProgramItem> visitedProgramItems = new HashSet<>();
633        visitedProgramItems.add(programItem);
634        return _getParentsOfType(programItem, visitedProgramItems, classToTest, continueIfFound);
635    }
636    
637    @SuppressWarnings("unchecked")
638    private <T> Set<T> _getParentsOfType(ProgramItem programItem, Set<ProgramItem> visitedProgramItems, Class<T> classToTest, boolean continueIfFound)
639    {
640        Set<T> parentsOfType = new HashSet<>();
641        List<ProgramItem> parents = getParentProgramItems(programItem);
642        
643        for (ProgramItem parent : parents)
644        {
645            // Only parents not already visited
646            if (visitedProgramItems.add(parent))
647            {
648                // Cast to Content if instance of Content instead of another type (for structures containing both Container and SubProgram)
649                boolean found = false;
650                if (classToTest.isInstance(parent))
651                {
652                    parentsOfType.add((T) parent);
653                    found = true;
654                }
655                
656                if (!found || continueIfFound)
657                {
658                    parentsOfType.addAll(_getParentsOfType(parent, visitedProgramItems, classToTest, continueIfFound));
659                }
660            }
661        }
662        
663        return parentsOfType;
664    }
665    
666    /**
667     * Get the programs that belong to a given {@link OrgUnit} or a child orgunit
668     * @param orgUnit The orgUnit. Can be null
669     * @param catalog The catalog. Can be null.
670     * @param lang The lang. Can be null.
671     * @return The child programs as unmodifiable list
672     */
673    public List<Program> getProgramsFromOrgUnit(OrgUnit orgUnit, String catalog, String lang)
674    {
675        return getProgramsFromOrgUnit(orgUnit, catalog, lang, true);
676    }
677    
678    /**
679     * Get the child programs of an {@link OrgUnit}
680     * @param orgUnit The orgUnit. Can be null
681     * @param catalog The catalog. Can be null.
682     * @param lang The lang. Can be null.
683     * @param browseChildOrgunits Set to true to get programs among child orgunits recursively, false to get only programs directly linked to the given orgunit.
684     * @return The child programs as unmodifiable list
685     */
686    public List<Program> getProgramsFromOrgUnit(OrgUnit orgUnit, String catalog, String lang, boolean browseChildOrgunits)
687    {
688        Expression ouExpr = null;
689        if (orgUnit != null)
690        {
691            if (!browseChildOrgunits)
692            {
693                ouExpr = new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnit.getId());
694            }
695            else
696            {
697                ouExpr = new OrExpression();
698                for (String orgUnitId : getSubOrgUnitIds(orgUnit))
699                {
700                    ((OrExpression) ouExpr).add(new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnitId));
701                }
702            }
703        }
704        
705        AmetysObjectIterable<Program> programs = getProgramItems(ProgramFactory.PROGRAM_CONTENT_TYPE, null, catalog, lang, ouExpr, null);
706        return programs.stream().toList();
707    }
708    
709    /**
710     * Get the current orgunit and its suborgunits recursively identifiers.
711     * @param orgUnit The orgunit at the top
712     * @return A {@link List} of {@link OrgUnit} ids
713     */
714    public List<String> getSubOrgUnitIds(OrgUnit orgUnit)
715    {
716        List<String> orgUnitIds = new ArrayList<>();
717        orgUnitIds.add(orgUnit.getId());
718        for (String id : orgUnit.getSubOrgUnits())
719        {
720            OrgUnit childOrgUnit = _resolver.resolveById(id);
721            orgUnitIds.addAll(getSubOrgUnitIds(childOrgUnit));
722        }
723        
724        return orgUnitIds;
725    }
726    
727    /**
728     * Get all program item linked to the given orgUnit
729     * @param orgUnit the given orgUnit
730     * @return the set of program item
731     */
732    public Set<Program> getProgramsReferencingOrgunit(OrgUnit orgUnit)
733    {
734        StringExpression stringExpression = new StringExpression(ProgramItem.ORG_UNITS_REFERENCES, Operator.EQ, orgUnit.getId());
735        String contentXPathQuery = ContentQueryHelper.getContentXPathQuery(stringExpression);
736        
737        return _resolver.<Content>query(contentXPathQuery)
738            .stream()
739            .filter(ProgramItem.class::isInstance)
740            .map(ProgramItem.class::cast)
741            .map(p -> getParentPrograms(p, true))
742            .flatMap(Set::stream)
743            .collect(Collectors.toSet());
744    }
745    
746    /**
747     * Get the linked program items to the given person
748     * @param person the person
749     * @return the set of program items
750     */
751    public Set<Program> getProgramsReferencingPerson(Person person)
752    {
753        return _contentHelper.getReferencingContents(person)
754            .stream()
755            .map(Pair::getValue)
756            .map(this::_getProgramsReferencingContent)
757            .flatMap(Set::stream)
758            .collect(Collectors.toSet());
759    }
760    
761    private Set<Program> _getProgramsReferencingContent(Content content)
762    {
763        if (content instanceof ProgramItem programItem)
764        {
765            return getParentPrograms(programItem, true);
766        }
767        else if (content instanceof OrgUnit orgUnit)
768        {
769            return getProgramsReferencingOrgunit(orgUnit);
770        }
771        
772        return Set.of();
773    }
774    
775    /**
776     * Determines if the {@link ProgramItem} has parent program items
777     * @param programItem The program item
778     * @return true if has parent program items
779     */
780    public boolean hasParentProgramItems(ProgramItem programItem)
781    {
782        boolean hasParent = false;
783        
784        if (programItem instanceof ProgramPart programPart)
785        {
786            hasParent = !programPart.getProgramPartParents().isEmpty() || hasParent;
787        }
788        
789        if (programItem instanceof CourseList courseList)
790        {
791            hasParent = !courseList.getParentCourses().isEmpty() || hasParent;
792        }
793        
794        if (programItem instanceof Course course)
795        {
796            hasParent = !course.getParentCourseLists().isEmpty() || hasParent;
797        }
798        
799        return hasParent;
800    }
801    
802    /**
803     * Get the parent program items of a {@link ProgramItem}
804     * @param programItem The program item
805     * @return The parent program items
806     */
807    public List<ProgramItem> getParentProgramItems(ProgramItem programItem)
808    {
809        return getParentProgramItems(programItem, null);
810    }
811    
812    /**
813     * Get the program item parents into the given ancestor {@link ProgramPart}
814     * @param programItem The program item
815     * @param parentProgramPart The parent program, subprogram or container. If null, all parents program items will be returned.
816     * @return The parent program items which have given parent program part has an ancestor
817     */
818    public List<ProgramItem> getParentProgramItems(ProgramItem programItem, ProgramPart parentProgramPart)
819    {
820        Cache<ParentProgramItemsCacheKey, List<ProgramItem>> cache = _cacheManager.get(__PARENT_PROGRAM_ITEMS_CACHE);
821        
822        return cache.get(ParentProgramItemsCacheKey.of(programItem.getId(), parentProgramPart != null ? parentProgramPart.getId() : "__NOPARENT"), item -> {
823            List<ProgramItem> parents = new ArrayList<>();
824            
825            if (programItem instanceof Program)
826            {
827                return parents;
828            }
829            
830            if (programItem instanceof ProgramPart programPart)
831            {
832                List<ProgramPart> allParents = programPart.getProgramPartParents();
833                
834                for (ProgramPart parent : allParents)
835                {
836                    if (parentProgramPart == null || parent.equals(parentProgramPart))
837                    {
838                        parents.add(parent);
839                    }
840                    else if (!getParentProgramItems(parent, parentProgramPart).isEmpty())
841                    {
842                        parents.add(parent);
843                    }
844                }
845            }
846            
847            if (programItem instanceof CourseList courseList)
848            {
849                for (Course parentCourse : courseList.getParentCourses())
850                {
851                    if (!getParentProgramItems(parentCourse, parentProgramPart).isEmpty())
852                    {
853                        parents.add(parentCourse);
854                    }
855                }
856            }
857            
858            if (programItem instanceof Course course)
859            {
860                for (CourseList cl : course.getParentCourseLists())
861                {
862                    if (!getParentProgramItems(cl, parentProgramPart).isEmpty())
863                    {
864                        parents.add(cl);
865                    }
866                    
867                }
868            }
869            
870            return parents;
871        });
872    }
873    
874    private static class ParentProgramItemsCacheKey extends AbstractCacheKey
875    {
876        public ParentProgramItemsCacheKey(String programItemId, String parentProgramPartId)
877        {
878            super(programItemId, parentProgramPartId);
879        }
880        
881        public static ParentProgramItemsCacheKey of(String programItemId, String parentProgramPartId)
882        {
883            return new ParentProgramItemsCacheKey(programItemId, parentProgramPartId);
884        }
885    }
886    
887    /**
888     * Get the first nearest program item parent into the given parent {@link AbstractProgram}
889     * @param programItem The program item
890     * @param parentProgram The parent program or subprogram. If null, the nearest abstract program will be returned.
891     * @return The parent program item or null if not found.
892     */
893    public ProgramItem getParentProgramItem(ProgramItem programItem, AbstractProgram parentProgram)
894    {
895        List<ProgramItem> parentProgramItems = getParentProgramItems(programItem, parentProgram);
896        return parentProgramItems.isEmpty() ? null : parentProgramItems.get(0);
897    }
898    
899    /**
900     * Get information of the program item
901     * @param programItemId the program item id
902     * @param programItemPathIds the list of program item ids containing in the path of the program item ... starting with itself. Can be null or empty
903     * @return a map of information
904     */
905    @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
906    public Map<String, Object> getProgramItemInfo(String programItemId, List<String> programItemPathIds)
907    {
908        Map<String, Object> results = new HashMap<>();
909        ProgramItem programItem = _resolver.resolveById(programItemId);
910
911        // Get catalog
912        String catalog = programItem.getCatalog();
913        if (StringUtils.isNotBlank(catalog))
914        {
915            results.put("catalog", catalog);
916        }
917        
918        // Get the orgunits
919        List<String> orgUnits = programItem.getOrgUnits();
920        if (programItemPathIds == null || programItemPathIds.isEmpty())
921        {
922            // The programItemPathIds is null or empty because we do not know the program item context.
923            // so get the information in the parent structure if unique.
924            while (programItem != null && orgUnits.isEmpty())
925            {
926                orgUnits = programItem.getOrgUnits();
927                List<ProgramItem> parentProgramItems = getParentProgramItems(programItem);
928                programItem = parentProgramItems.size() == 1 ? parentProgramItems.get(0) : null;
929            }
930        }
931        else // We have the program item context: parent structure is known ...
932        {
933            // ... the first element of the programItemPathIds is the programItem itself, so begin to index 1
934            int position = 1;
935            int size = programItemPathIds.size();
936            while (position < size && orgUnits.isEmpty())
937            {
938                programItem = _resolver.resolveById(programItemPathIds.get(position));
939                orgUnits = programItem.getOrgUnits();
940                position++;
941            }
942        }
943        results.put("orgUnits", orgUnits);
944        
945        return results;
946    }
947    
948    /**
949     * Get information of the program item structure (type, if program has children) or orgunit (no structure for now)
950     * @param contentId the content id
951     * @return a map of information
952     */
953    @Callable (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
954    public Map<String, Object> getStructureInfo(String contentId)
955    {
956        if (StringUtils.isNotBlank(contentId))
957        {
958            Content content = _resolver.resolveById(contentId);
959            return getStructureInfo(content);
960        }
961        
962        return Map.of();
963    }
964    
965    /**
966     * Get information of the program item structure (type, if program has children) or orgunit (no structure for now)
967     * @param content the content
968     * @return a map of information
969     */
970    public Map<String, Object> getStructureInfo(Content content)
971    {
972        Map<String, Object> results = new HashMap<>();
973        
974        if (content instanceof ProgramItem programItem)
975        {
976            results.put("id", content.getId());
977            results.put("title", content.getTitle());
978            results.put("code", programItem.getDisplayCode());
979            
980            List<ProgramItem> childProgramItems = getChildProgramItems(programItem);
981            results.put("hasChildren", !childProgramItems.isEmpty());
982            
983            List<ProgramItem> parentProgramItems = getParentProgramItems(programItem);
984            results.put("hasParent", !parentProgramItems.isEmpty());
985            
986            results.put("paths", getPaths(programItem, " > "));
987        }
988        else if (content instanceof OrgUnit orgunit)
989        {
990            results.put("id", content.getId());
991            results.put("title", content.getTitle());
992            results.put("code", orgunit.getUAICode());
993            
994            // Always to false, we don't manage complete copy with children
995            results.put("hasChildren", false);
996            
997            results.put("hasParent", orgunit.getParentOrgUnit() != null);
998            
999            results.put("paths", List.of(getOrgUnitPath(orgunit, " > ")));
1000        }
1001        
1002        return results;
1003    }
1004    
1005    /**
1006     * Get information of the program item structure (type, if program has children)
1007     * @param programItemIds the list of program item id
1008     * @return a map of information
1009     */
1010    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
1011    public Map<String, Map<String, Object>> getStructureInfo(List<String> programItemIds)
1012    {
1013        Map<String,  Map<String, Object>> results = new HashMap<>();
1014        
1015        for (String programItemId : programItemIds)
1016        {
1017            Content content = _resolver.resolveById(programItemId);
1018            if (_rightManager.currentUserHasReadAccess(content))
1019            {
1020                results.put(programItemId, getStructureInfo(content));
1021            }
1022        }
1023        
1024        return results;
1025    }
1026    
1027    /**
1028     * Get all the path of the orgunit.<br>
1029     * The path is built with the contents' title and code
1030     * @param orgunit The orgunit
1031     * @param separator The path separator
1032     * @return the path in parent orgunit
1033     */
1034    public String getOrgUnitPath(OrgUnit orgunit, String separator)
1035    {
1036        String path = orgunit.getTitle() + " (" + orgunit.getUAICode() + ")";
1037        OrgUnit parent = orgunit.getParentOrgUnit();
1038        if (parent != null)
1039        {
1040            path = getOrgUnitPath(parent, separator) + separator + path;
1041        }
1042        return path;
1043    }
1044    
1045    /**
1046     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1047     * The path is built with the contents' title and code
1048     * @param item The program item
1049     * @param separator The path separator
1050     * @return the paths in parent program items
1051     */
1052    public List<String> getPaths(ProgramItem item, String separator)
1053    {
1054        Function<ProgramItem, String> mapper = c -> ((Content) c).getTitle() + " (" + c.getDisplayCode() + ")";
1055        return getPaths(item, separator, mapper, true);
1056    }
1057    
1058    /**
1059     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1060     * The path is built with the mapper function.
1061     * @param item The program item
1062     * @param separator The path separator
1063     * @param mapper the function to apply to each program item to build the path
1064     * @param includeItself set to false to not include final item in path
1065     * @return the paths in parent program items
1066     */
1067    public List<String> getPaths(ProgramItem item, String separator, Function<ProgramItem, String> mapper, boolean includeItself)
1068    {
1069        return getPaths(item, separator, mapper, x -> true, includeItself, false);
1070    }
1071    
1072    /**
1073     * Get all {@link EducationalPath} of a {@link ProgramItem} as readable values
1074     * The path is built with the mapper function.
1075     * @param item The program item
1076     * @param separator The path separator
1077     * @param mapper the function to apply to each program item to build the path
1078     * @param filterPathSegment predicate to exclude some program item of path
1079     * @param includeItself set to false to not include final item in path
1080     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1081     * @return the paths in parent program items
1082     */
1083    public List<String> getPaths(ProgramItem item, String separator, Function<ProgramItem, String> mapper, Predicate<ProgramItem> filterPathSegment, boolean includeItself, boolean ignoreOrphanPath)
1084    {
1085        List<EducationalPath> educationalPaths = getEducationalPaths(item, includeItself, ignoreOrphanPath);
1086        
1087        return educationalPaths.stream()
1088            .map(p -> getEducationalPathAsString(p, mapper, separator, filterPathSegment))
1089            .collect(Collectors.toList());
1090    }
1091    
1092    /**
1093     * Get the value of an attribute for a given educational path. The attribute is necessarily in a repeater composed of at least one educational path attribute and the attribute to be retrieved.
1094     * @param <T> The type of the value returned by the path
1095     * @param programItem The program item
1096     * @param path Full or partial educational path. Cannot be null. In case of partial educational path (ie., no root program) the full possible paths will be computed.
1097     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1098     * @return the value for this educational path
1099     */
1100    public <T> Optional<T> getValueForPath(ProgramItem programItem, String dataPath, EducationalPath path)
1101    {
1102        return getValueForPath(programItem, dataPath, List.of(path));
1103    }
1104    
1105    
1106    /**
1107     * Get the value of an attribute for a given educational path. The attribute is necessarily in a repeater composed of at least one educational path attribute and the attribute to be retrieved.
1108     * @param <T> The type of the value returned by the path
1109     * @param programItem The program item
1110     * @param paths Full educational paths (from root program). Cannot be null nor empty.
1111     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1112     * @return the value for this educational path
1113     */
1114    public <T> Optional<T> getValueForPath(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1115    {
1116        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1117        String attributeName = StringUtils.substringAfterLast(dataPath, "/");
1118        
1119        if (!programItem.hasValue(repeaterPath))
1120        {
1121            // Repeater is not defined
1122            getLogger().warn("There is no repeater '{}' defined for content '{}'", repeaterPath, programItem.getId());
1123            return Optional.empty();
1124        }
1125        
1126        // Get the repeater
1127        Repeater repeater = programItem.getRepeater(repeaterPath);
1128        
1129        // Get a value for each value in paths because if it is multiple, we want to check all paths, keep null values
1130        List<T> values = paths.stream()
1131             .map(path ->
1132                 getRepeaterEntriesByPath(repeater, List.of(path))
1133                     .findFirst()
1134                     .map(e -> e.<T>getValue(attributeName))
1135                     .orElse(null)
1136             )
1137             .toList();
1138        
1139        boolean isSameValues = values.stream().distinct().count() == 1;
1140        if (!isSameValues)
1141        {
1142            // No same values for each path
1143            getLogger().warn("Unable to determine value for '{}' attribute of content '{}'. Multiple educational paths are available for requested context with no same values", dataPath, programItem.getId());
1144            return Optional.empty();
1145        }
1146
1147        // Same value for each available paths => return this common value
1148        return values.stream().filter(Objects::nonNull).findFirst();
1149    }
1150    
1151    /**
1152     * Get the value of an attribute for each available educational paths
1153     * @param <T> The type of the values returned by the path
1154     * @param programItem The program item
1155     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1156     * @param defaultValue The default value to use if the repeater contains no entry for this educational path. Can be null.
1157     * @return the values for each educational paths
1158     */
1159    public <T> Map<EducationalPath, T> getValuesForPaths(ProgramItem programItem, String dataPath, T defaultValue)
1160    {
1161        return getValuesForPaths(programItem, dataPath, getEducationalPaths(programItem), defaultValue);
1162    }
1163    
1164    /**
1165     * Get the value of an attribute for each given educational paths
1166     * @param <T> The type of the value returned by the path
1167     * @param programItem The program item
1168     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1169     * @param paths The full educational paths (from root programs)
1170     * @param defaultValue The default value to use if the repeater contains no entry for this educational path. Can be null.
1171     * @return the values for each educational paths
1172     */
1173    @SuppressWarnings("unchecked")
1174    public <T> Map<EducationalPath, T> getValuesForPaths(ProgramItem programItem, String dataPath, List<EducationalPath> paths, T defaultValue)
1175    {
1176        Map<EducationalPath, T> valuesByPath = new HashMap<>();
1177        
1178        paths.stream().forEach(path -> {
1179            valuesByPath.put(path, (T) getValueForPath(programItem, dataPath, path).orElse(defaultValue));
1180        });
1181        
1182        return valuesByPath;
1183    }
1184    
1185    /**
1186     * Determines if the values of an attribute depending of a educational path is the same for all available educational paths
1187     * @param programItem The program item
1188     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1189     * @return true if the value is the same for all available educational paths
1190     */
1191    public boolean isSameValueForAllPaths(ProgramItem programItem, String dataPath)
1192    {
1193        return isSameValueForPaths(programItem, dataPath, getEducationalPaths(programItem));
1194    }
1195    
1196    /**
1197     * Determines if the values of an attribute depending of a educational path is the same for all available educational paths
1198     * @param programItem The program item
1199     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1200     * @param paths The full educational paths (from root programs)
1201     * @return true if the value is the same for all available educational paths
1202     */
1203    public boolean isSameValueForPaths(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1204    {
1205        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1206        if (!programItem.hasValue(repeaterPath))
1207        {
1208            // Repeater is empty, the value is the default value for all educational paths
1209            return true;
1210        }
1211        
1212        return getValuesForPaths(programItem, dataPath, paths, null).values().stream().distinct().count() == 1;
1213    }
1214    
1215    /**
1216     * Get a position of repeater entry that match the given educational path
1217     * @param programItem The program item
1218     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1219     * @param path The full educational path (from root program). Cannot be null.
1220     * @return the index position of the entry matching the educational path with a non-empty value for requested attribute, -1 otherwise
1221     */
1222    public int getRepeaterEntryPositionForPath(ProgramItem programItem, String dataPath, EducationalPath path)
1223    {
1224        return getRepeaterEntryPositionForPath(programItem, dataPath, List.of(path));
1225    }
1226    
1227    /**
1228     * Get a position of repeater entry that match the given educational path
1229     * @param programItem The program item
1230     * @param dataPath The full path of attribute to retrieve. The path must contain the path of repeater holding the value (ex: path/to/repeater/attributeName)
1231     * @param paths The full educational paths (from root program). Cannot be null.
1232     * @return the index position of the entry matching the educational path with a non-empty value for requested attribute, -1 otherwise
1233     */
1234    public int getRepeaterEntryPositionForPath(ProgramItem programItem, String dataPath, List<EducationalPath> paths)
1235    {
1236        String repeaterPath = StringUtils.substringBeforeLast(dataPath, "/");
1237        String attributeName = StringUtils.substringAfterLast(dataPath, "/");
1238        
1239        if (!programItem.hasValue(repeaterPath))
1240        {
1241            // Repeater is not defined
1242            getLogger().debug("There is no repeater '{}' defined for content '{}'", repeaterPath, programItem.getId());
1243            return -1;
1244        }
1245        
1246        // Get the repeater
1247        Repeater repeater = programItem.getRepeater(repeaterPath);
1248        
1249        // Get a value for each value in paths because if it is multiple, we want to check all paths
1250        List<Pair<Integer, Object>> values = paths.stream()
1251             .map(path ->
1252                 getRepeaterEntriesByPath(repeater, List.of(path))
1253                     .findFirst()
1254                     .map(e -> Pair.of(e.getPosition(), e.getValue(attributeName)))
1255                     .orElseGet(() -> Pair.of(-1, null))
1256             )
1257             .toList();
1258        
1259        boolean isSameValues = values.stream().map(Pair::getRight).distinct().count() == 1;
1260        if (!isSameValues)
1261        {
1262            // No same values for each path
1263            getLogger().warn("Unable to determine repeater entry for '{}' attribute of content '{}'. Multiple educational paths are available for requested context with no same values", dataPath, programItem.getId());
1264            return -1;
1265        }
1266        
1267        // Same value for each available paths => return position of any entry
1268        return values.stream().findFirst().map(Pair::getLeft).orElse(-1);
1269    }
1270    
1271    /**
1272     * Get the full educations paths (from a root {@link Program}) from a full or partial path
1273     * @param path A full or partial path composed by program item ancestors
1274     * @return the full educational paths
1275     */
1276    public List<EducationalPath> getEducationPathFromPath(EducationalPath path)
1277    {
1278        return getEducationPathFromPath(path.getProgramItems(_resolver));
1279    }
1280    
1281    /**
1282     * Get the full educations paths (from a root {@link Program}) from a full or partial path
1283     * @param path A full or partial path composed by program item ancestors
1284     * @return the full educational paths
1285     */
1286    public List<EducationalPath> getEducationPathFromPath(List<ProgramItem> path)
1287    {
1288        return getEducationPathFromPaths(List.of(path));
1289    }
1290    
1291    /**
1292     * Get the full educations paths (from a root {@link Program}) from full or partial paths
1293     * @param paths full or partial paths composed by program item ancestors
1294     * @return the full educational paths
1295     */
1296    public List<EducationalPath> getEducationPathFromPaths(List<List<ProgramItem>> paths)
1297    {
1298        return getEducationPathFromPaths(paths, null);
1299    }
1300    
1301    /**
1302     * Get the full educations paths (from a root {@link Program})from full or partial paths
1303     * @param paths full or partial paths composed by program item ancestors
1304     * @param withAncestor filter the educational paths that contains this ancestor. Can be null.
1305     * @return the full educational paths
1306     */
1307    public List<EducationalPath> getEducationPathFromPaths(List<List<ProgramItem>> paths, ProgramItem withAncestor)
1308    {
1309        List<EducationalPath> fullPaths = new ArrayList<>();
1310        
1311        for (List<ProgramItem> partialPath : paths)
1312        {
1313            ProgramItem firstProgramItem = partialPath.get(0);
1314            if (!(firstProgramItem instanceof Program))
1315            {
1316                // First program item of path is not a root program => computed the available full paths from this first item ancestors
1317                List<EducationalPath> parentEducationalPaths = getEducationalPaths(firstProgramItem, false);
1318                
1319                fullPaths.addAll(parentEducationalPaths.stream()
1320                    .filter(p -> withAncestor == null || p.getProgramItems(_resolver).contains(withAncestor)) // filter educational paths that is not composed by the required ancestor
1321                    .map(p -> EducationalPath.of(p, partialPath.toArray(ProgramItem[]::new))) // concat path
1322                    .toList());
1323            }
1324            else if (withAncestor == null || partialPath.contains(withAncestor))
1325            {
1326                // The path is already a full path
1327                fullPaths.add(EducationalPath.of(partialPath.toArray(ProgramItem[]::new)));
1328            }
1329        }
1330        
1331        return fullPaths;
1332    }
1333    
1334    /**
1335     * Get all {@link EducationalPath} of a {@link ProgramItem}
1336     * The path is built with the mapper function.
1337     * @param programItem The program item
1338     * @return the paths in parent program items
1339     */
1340    public List<EducationalPath> getEducationalPaths(ProgramItem programItem)
1341    {
1342        return getEducationalPaths(programItem, true);
1343    }
1344    
1345    /**
1346     * Get all {@link EducationalPath} of a {@link ProgramItem}
1347     * The path is built with the mapper function.
1348     * @param programItem The program item
1349     * @param includeItself set to false to not include final item in path
1350     * @return the paths in parent program items
1351     */
1352    public List<EducationalPath> getEducationalPaths(ProgramItem programItem, boolean includeItself)
1353    {
1354        return getEducationalPaths(programItem, includeItself, false);
1355    }
1356    
1357    /**
1358     * Get all {@link EducationalPath} of a {@link ProgramItem}
1359     * The path is built with the mapper function.
1360     * @param programItem The program item
1361     * @param includeItself set to false to not include final item in path
1362     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1363     * @return the paths in parent program items
1364     */
1365    public List<EducationalPath> getEducationalPaths(ProgramItem programItem, boolean includeItself, boolean ignoreOrphanPath)
1366    {
1367        List<EducationalPath> paths = new ArrayList<>();
1368        
1369        List<List<ProgramItem>> ancestorPaths = getPathOfAncestors(programItem);
1370        for (List<ProgramItem> ancestorPath : ancestorPaths)
1371        {
1372            if (!ignoreOrphanPath || ancestorPath.get(0) instanceof Program) // ignore paths that is not part of a Program if ignoreOrphanPath is true
1373            {
1374                // Don't modify directly the returned value, it may be immutable and cause side effects
1375                List<ProgramItem> ancestorPathCopy = new ArrayList<>(ancestorPath);
1376                if (!includeItself)
1377                {
1378                    ancestorPathCopy.remove(programItem);
1379                }
1380                
1381                if (!ancestorPathCopy.isEmpty())
1382                {
1383                    paths.add(EducationalPath.of(ancestorPathCopy.toArray(ProgramItem[]::new)));
1384                }
1385            }
1386        }
1387        
1388        return paths;
1389    }
1390    
1391    /**
1392     * Get a readable value of a {@link EducationalPath}
1393     * @param path the educational path
1394     * @return a String representing the path with program item's title separated by '>'
1395     */
1396    public String getEducationalPathAsString(EducationalPath path)
1397    {
1398        return getEducationalPathAsString(path, pi -> ((Content) pi).getTitle(), " > ");
1399    }
1400    
1401    /**
1402     * Get a readable value of a {@link EducationalPath}
1403     * @param path the educational path
1404     * @param mapper the function to use for the readable value of a program item
1405     * @param separator the separator to use
1406     * @return a String representing the path with program item's readable value given by mapper function and separated by given separator
1407     */
1408    public String getEducationalPathAsString(EducationalPath path, Function<ProgramItem, String> mapper, CharSequence separator)
1409    {
1410        return getEducationalPathAsString(path, mapper, separator, x -> true);
1411    }
1412    
1413    /**
1414     * Get a readable value of a {@link EducationalPath}
1415     * @param path the educational path
1416     * @param mapper the function to use for the readable value of a program item
1417     * @param separator the separator to use
1418     * @param filterPathSegment predicate to exclude some program item of path
1419     * @return a String representing the path with program item's readable value given by mapper function and separated by given separator
1420     */
1421    public String getEducationalPathAsString(EducationalPath path, Function<ProgramItem, String> mapper, CharSequence separator, Predicate<ProgramItem> filterPathSegment)
1422    {
1423        return path.resolveProgramItems(_resolver)
1424                .filter(filterPathSegment)
1425                .map(mapper)
1426                .collect(Collectors.joining(separator));
1427    }
1428    
1429    /**
1430     * Determines if a educational path is valid ODF path
1431     * @param path the educational path
1432     * @return <code>true</code> if path represents a valid ODF path
1433     */
1434    public boolean isValid(EducationalPath path)
1435    {
1436        // Get leaf program item of this educational path
1437        String leafProgramItemId = path.getProgramItemIds().getLast();
1438        if (_resolver.hasAmetysObjectForId(leafProgramItemId))
1439        {
1440            ProgramItem leafProgramItem = _resolver.resolveById(leafProgramItemId);
1441            return isValid(path, leafProgramItem, false);
1442        }
1443        return false;
1444    }
1445    
1446    
1447    /**
1448     * Determines if the given educational path is a valid path for the program item
1449     * @param path the educational path. The path must include the program item itself
1450     * @param programItem the program item
1451     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1452     * @return <code>true</code> if path is valid for the given program item
1453     */
1454    public boolean isValid(EducationalPath path, ProgramItem programItem, boolean ignoreOrphanPath)
1455    {
1456        return getEducationalPaths(programItem, true, ignoreOrphanPath).contains(path);
1457    }
1458    
1459    /**
1460     * Get the full path to program item for highest ancestors. The path includes this final item.
1461     * @param programItem the program item
1462     * @return a list for each highest ancestors found. Each item of the list contains the program items to the path to this program item.
1463     */
1464    protected List<List<ProgramItem>> getPathOfAncestors(ProgramItem programItem)
1465    {
1466        Cache<ProgramItem, List<List<ProgramItem>>> cache = _cacheManager.get(__ANCESTORS_CACHE);
1467        
1468        return cache.get(programItem, item -> {
1469            List<ProgramItem> parentProgramItems = getParentProgramItems(item);
1470            
1471            // There is no more parents, the only path is the item itself
1472            if (parentProgramItems.isEmpty())
1473            {
1474                return List.of(List.of(item));
1475            }
1476            
1477            List<List<ProgramItem>> ancestors = new ArrayList<>();
1478            
1479            // Compute the path for each parent
1480            for (ProgramItem parentProgramItem : parentProgramItems)
1481            {
1482                for (List<ProgramItem> ancestorPaths : getPathOfAncestors(parentProgramItem))
1483                {
1484                    List<ProgramItem> ancestorPathsCopy = new ArrayList<>(ancestorPaths);
1485                    ancestorPathsCopy.add(item);
1486                    // Add an immutable list to avoid unvoluntary modifications in cache
1487                    ancestors.add(Collections.unmodifiableList(ancestorPathsCopy));
1488                }
1489            }
1490
1491            // Add an immutable list to avoid unvoluntary modifications in cache
1492            return Collections.unmodifiableList(ancestors);
1493        });
1494    }
1495    
1496    /**
1497     * Get the enumeration of educational paths for a program item for highest ancestors. The paths does not includes this final item.
1498     * @param programItemId the id of program item
1499     * @return a list of educational paths with paths' label (composed by ancestors' title separated by '>') and paths' id (composed by ancestors' id seperated by coma)
1500     */
1501    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1502    public List<Map<String, String>> getEducationalPathsEnumeration(String programItemId)
1503    {
1504        List<Map<String, String>> paths = new ArrayList<>();
1505        
1506        ProgramItem programItem = _resolver.resolveById(programItemId);
1507        
1508        getEducationalPaths(programItem)
1509            .stream()
1510            .forEach(p -> paths.add(Map.of("id", p.toString(), "title", getEducationalPathAsString(p, c -> ((Content) c).getTitle(), " > "))));
1511        
1512        return paths;
1513    }
1514    
1515    /**
1516     * Get the parent program enumeration for a program item
1517     * @param programItemId The id of the program item
1518     * @return The enumeration of parent programs
1519     */
1520    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1521    public List<Map<String, Object>> getParentProgramEnumeration(String programItemId)
1522    {
1523        try
1524        {
1525            Content content = _resolver.resolveById(programItemId);
1526            if (content instanceof ProgramItem programItem)
1527            {
1528                List<Map<String, Object>> enumeration = new ArrayList<>();
1529                
1530                getParentPrograms(programItem)
1531                        .stream()
1532                        .forEach(p -> enumeration.add(Map.of("id", p.getId(), "title", p.getTitle())));
1533                
1534                return enumeration;
1535            }
1536            else
1537            {
1538                throw new IllegalArgumentException("Tried to retreieve the parent programs enumeration of the content '" + content.getTitle() + "' (" + content.getId() + ") that is not a program item.");
1539            }
1540        }
1541        catch (AmetysRepositoryException e)
1542        {
1543            throw new IllegalArgumentException("Tried to retreieve the parent program enumeration on an unexisting content", e);
1544        }
1545    }
1546    
1547    /**
1548     * Get the path of a {@link ProgramItem} into a {@link Program}<br>
1549     * The path is construct with the contents' names and the used separator is '/'.
1550     * @param programItemId The id of the program item
1551     * @param programId The id of program. Can not be null.
1552     * @return the path into the parent program or null if the item is not part of this program.
1553     */
1554    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1555    public String getPathInProgram (String programItemId, String programId)
1556    {
1557        ProgramItem item = _resolver.resolveById(programItemId);
1558        Program program = _resolver.resolveById(programId);
1559        
1560        return getPathInProgram(item, program);
1561    }
1562    
1563    /**
1564     * Get the path of a ODF content into a {@link Program}.<br>
1565     * The path is construct with the contents' names and the used separator is '/'.
1566     * @param item The program item
1567     * @param parentProgram The parent root (sub)program. Can not be null.
1568     * @return the path from the parent program
1569     */
1570    public String getPathInProgram (ProgramItem item, Program parentProgram)
1571    {
1572        if (item instanceof Program)
1573        {
1574            // The program item is already the program it self or another program
1575            return item.equals(parentProgram) ? "" : null;
1576        }
1577        
1578        List<EducationalPath> paths = getEducationalPaths(item, true, true);
1579        
1580        for (EducationalPath path : paths)
1581        {
1582            if (path.getProgramItemIds().contains(parentProgram.getId()))
1583            {
1584                // Find a path that match the given parent program
1585                Stream<ProgramItem> resolvedPath = path.resolveProgramItems(_resolver);
1586                return resolvedPath.map(ProgramItem::getName).collect(Collectors.joining("/"));
1587            }
1588        }
1589        
1590        return null;
1591    }
1592    
1593    /**
1594     * Get the path of a {@link Course} or a {@link CourseList} into a {@link Course}<br>
1595     * The path is construct with the contents' names and the used separator is '/'.
1596     * @param contentId The id of the content
1597     * @param parentCourseId The id of parent course. Can not be null.
1598     * @return the path into the parent course or null if the item is not part of this course.
1599     */
1600    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1601    public String getPathInCourse (String contentId, String parentCourseId)
1602    {
1603        Content content = _resolver.resolveById(contentId);
1604        Course parentCourse = _resolver.resolveById(parentCourseId);
1605        
1606        return getPathInCourse(content, parentCourse);
1607    }
1608    
1609    /**
1610     * Get the path of a {@link Course}  or a {@link CourseList} into a {@link Course}<br>
1611     * The path is construct with the contents' names and the used separator is '/'.
1612     * @param courseOrList The course or the course list
1613     * @param parentCourse The parent course. Can not be null.
1614     * @return the path into the parent course or null if the item is not part of this course.
1615     */
1616    public String getPathInCourse(Content courseOrList, Course parentCourse)
1617    {
1618        if (courseOrList.equals(parentCourse))
1619        {
1620            return "";
1621        }
1622        
1623        String path = _getPathInCourse(courseOrList, parentCourse);
1624        
1625        return path;
1626    }
1627    
1628    private String _getPathInCourse(Content content, Content parentContent)
1629    {
1630        if (content.equals(parentContent))
1631        {
1632            return content.getName();
1633        }
1634
1635        List<? extends Content> parents;
1636        
1637        if (content instanceof Course course)
1638        {
1639            parents = course.getParentCourseLists();
1640        }
1641        else if (content instanceof CourseList courseList)
1642        {
1643            parents = courseList.getParentCourses();
1644        }
1645        else
1646        {
1647            throw new IllegalStateException();
1648        }
1649        
1650        for (Content parent : parents)
1651        {
1652            String path = _getPathInCourse(parent, parentContent);
1653            if (path != null)
1654            {
1655                return path + '/' + content.getName();
1656            }
1657        }
1658        return null;
1659    }
1660    
1661    /**
1662     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit id.<br>
1663     * The path is construct with the contents' names and the used separator is '/'.
1664     * @param orgUnitId The id of the orgunit
1665     * @param rootOrgUnitId The root orgunit id
1666     * @return the path into the parent program or null if the item is not part of this program.
1667     */
1668    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1669    public String getOrgUnitPath(String orgUnitId, String rootOrgUnitId)
1670    {
1671        OrgUnit rootOU = null;
1672        if (StringUtils.isNotBlank(rootOrgUnitId))
1673        {
1674            rootOU = _resolver.resolveById(rootOrgUnitId);
1675        }
1676        else
1677        {
1678            rootOU = _ouRootProvider.getRoot();
1679        }
1680        
1681        if (orgUnitId.equals(rootOU.getId()))
1682        {
1683            // The orgunit is already the root orgunit
1684            return rootOU.getName();
1685        }
1686        
1687        OrgUnit ou = _resolver.resolveById(orgUnitId);
1688        
1689        List<String> paths = new ArrayList<>();
1690        paths.add(ou.getName());
1691        
1692        OrgUnit parent = ou.getParentOrgUnit();
1693        while (parent != null && !parent.getId().equals(rootOU.getId()))
1694        {
1695            paths.add(parent.getName());
1696            parent = parent.getParentOrgUnit();
1697        }
1698        
1699        if (parent != null)
1700        {
1701            paths.add(rootOU.getName());
1702            Collections.reverse(paths);
1703            return StringUtils.join(paths, "/");
1704        }
1705        
1706        return null;
1707    }
1708    
1709    /**
1710     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit.<br>
1711     * The path is construct with the contents' names and the used separator is '/'.
1712     * @param orgUnitId The id of the orgunit
1713     * @return the path into the parent program or null if the item is not part of this program.
1714     */
1715    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1716    public String getOrgUnitPath(String orgUnitId)
1717    {
1718        return getOrgUnitPath(orgUnitId, null);
1719    }
1720    
1721    /**
1722     * Return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1723     * @param part The program part
1724     * @param parentId The ancestor id
1725     * @return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1726     */
1727    public boolean hasAncestor (ProgramPart part, String parentId)
1728    {
1729        List<ProgramPart> parents = part.getProgramPartParents();
1730        
1731        for (ProgramPart parent : parents)
1732        {
1733            if (parent.getId().equals(parentId))
1734            {
1735                return true;
1736            }
1737            else if (hasAncestor(parent, parentId))
1738            {
1739                return true;
1740            }
1741        }
1742        
1743        return false;
1744    }
1745    
1746    /**
1747     * Determines if a program item is shared
1748     * @param programItem the program item
1749     * @return true if the program item is shared
1750     */
1751    public boolean isShared(ProgramItem programItem)
1752    {
1753        List<ProgramItem> parents = getParentProgramItems(programItem);
1754        if (parents.size() > 1)
1755        {
1756            return true;
1757        }
1758        else
1759        {
1760            return parents.isEmpty() ? false : isShared(parents.get(0));
1761        }
1762    }
1763    
1764    /**
1765     * Check if a relation can be establish between two ODF contents
1766     * @param srcContent The source content (copied or moved)
1767     * @param targetContent The target content
1768     * @param errors The list of error messages
1769     * @param contextualParameters the contextual parameters
1770     * @return true if the relation is valid, false otherwise
1771     */
1772    public boolean isRelationCompatible(Content srcContent, Content targetContent, List<I18nizableText> errors, Map<String, Object> contextualParameters)
1773    {
1774        boolean isCompatible = true;
1775        
1776        if (targetContent instanceof ProgramItem || targetContent instanceof OrgUnit)
1777        {
1778            if (!_isContentTypeCompatible(srcContent, targetContent))
1779            {
1780                // Invalid relations between content types
1781                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CONTENT_TYPES", _getContentParameters(srcContent, targetContent)));
1782                isCompatible = false;
1783            }
1784            else if (!_isCatalogCompatible(srcContent, targetContent))
1785            {
1786                // Catalog is invalid
1787                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CATALOG", _getContentParameters(srcContent, targetContent)));
1788                isCompatible = false;
1789            }
1790            else if (!_isLanguageCompatible(srcContent, targetContent))
1791            {
1792                // Language is invalid
1793                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_LANGUAGE", _getContentParameters(srcContent, targetContent)));
1794                isCompatible = false;
1795            }
1796            else if (!_areShareableFieldsCompatibles(srcContent, targetContent, contextualParameters))
1797            {
1798                // Shareable fields don't match
1799                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_SHAREABLE_COURSE", _getContentParameters(srcContent, targetContent)));
1800                isCompatible = false;
1801            }
1802        }
1803        else if (srcContent instanceof ProgramItem || srcContent instanceof OrgUnit)
1804        {
1805            // If the target isn't ODF related but the source is, the relation is not compatible.
1806            errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_NO_PROGRAM_ITEM", _getContentParameters(srcContent, targetContent)));
1807            isCompatible = false;
1808        }
1809        
1810        return isCompatible;
1811    }
1812    
1813    /**
1814     * Get the name of attribute holding the relation between a parent content and its children
1815     * @param parentProgramItem the parent content
1816     * @param childProgramItem the child content
1817     * @return the name of attribute the child relation
1818     */
1819    public String getDescendantRelationAttributeName(ProgramItem parentProgramItem, ProgramItem childProgramItem)
1820    {
1821        if (parentProgramItem instanceof CourseList && childProgramItem instanceof Course)
1822        {
1823            return CourseList.CHILD_COURSES;
1824        }
1825        else if (parentProgramItem instanceof Course && childProgramItem instanceof CourseList)
1826        {
1827            return Course.CHILD_COURSE_LISTS;
1828        }
1829        else if (parentProgramItem instanceof Course && childProgramItem instanceof CoursePart)
1830        {
1831            return Course.CHILD_COURSE_PARTS;
1832        }
1833        else if (parentProgramItem instanceof TraversableProgramPart && childProgramItem instanceof ProgramPart)
1834        {
1835            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
1836        }
1837        
1838        return null;
1839    }
1840    
1841    private boolean _isCourseAlreadyBelongToCourseList(Course course, CourseList courseList)
1842    {
1843        return courseList.getCourses().contains(course);
1844    }
1845    
1846    private boolean _isContentTypeCompatible(Content srcContent, Content targetContent)
1847    {
1848        if (srcContent instanceof Container || srcContent instanceof SubProgram)
1849        {
1850            return targetContent instanceof AbstractTraversableProgramPart;
1851        }
1852        else if (srcContent instanceof CourseList)
1853        {
1854            return targetContent instanceof CourseListContainer;
1855        }
1856        else if (srcContent instanceof Course)
1857        {
1858            return targetContent instanceof CourseList;
1859        }
1860        else if (srcContent instanceof OrgUnit)
1861        {
1862            return targetContent instanceof OrgUnit;
1863        }
1864        
1865        return false;
1866    }
1867    
1868    private boolean _isCatalogCompatible(Content srcContent, Content targetContent)
1869    {
1870        if (srcContent instanceof ProgramItem srcProgramItem && targetContent instanceof ProgramItem targetProgramItem)
1871        {
1872            return srcProgramItem.getCatalog().equals(targetProgramItem.getCatalog());
1873        }
1874        return true;
1875    }
1876    
1877    private boolean _isLanguageCompatible(Content srcContent, Content targetContent)
1878    {
1879        return srcContent.getLanguage().equals(targetContent.getLanguage());
1880    }
1881    
1882    private boolean _areShareableFieldsCompatibles(Content srcContent, Content targetContent, Map<String, Object> contextualParameters)
1883    {
1884        // We check shareable fields only if the course content is not created (or created by copy) and not moved
1885        if (srcContent instanceof Course srcCourse
1886                && targetContent instanceof CourseList targetCourseList
1887                && _shareableCourseHelper.handleShareableCourse()
1888                && !"create".equals(contextualParameters.get("mode"))
1889                && !"copy".equals(contextualParameters.get("mode"))
1890                && !"move".equals(contextualParameters.get("mode"))
1891                // In this case, it means that we try to change the position of the course in the courseList, so don't check shareable fields
1892                && !_isCourseAlreadyBelongToCourseList(srcCourse, targetCourseList))
1893        {
1894            return _shareableCourseHelper.isShareableFieldsMatch(srcCourse, targetCourseList);
1895        }
1896        
1897        return true;
1898    }
1899    
1900    private List<String> _getContentParameters(Content srcContent, Content targetContent)
1901    {
1902        List<String> parameters = new ArrayList<>();
1903        parameters.add(srcContent.getTitle());
1904        parameters.add(srcContent.getId());
1905        parameters.add(targetContent.getTitle());
1906        parameters.add(targetContent.getId());
1907        return parameters;
1908    }
1909    /**
1910     * Copy a {@link ProgramItem}
1911     * @param srcContent The program item to copy
1912     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1913     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1914     * @param copiedContents the initial contents with their copied content
1915     * @return The created content
1916     * @param <C> The modifiable content return type
1917     * @throws AmetysRepositoryException If an error occurred during copy
1918     * @throws WorkflowException If an error occurred during copy
1919     */
1920    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1921    {
1922        return copyProgramItem(srcContent, null, null, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1923    }
1924    
1925    /**
1926     * Copy a {@link ProgramItem}
1927     * @param srcContent The program item to copy
1928     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1929     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1930     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1931     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1932     * @param copiedContents the initial contents with their copied content
1933     * @param <C> The modifiable content return type
1934     * @return The created content
1935     * @throws AmetysRepositoryException If an error occurred
1936     * @throws WorkflowException If an error occurred
1937     */
1938    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetContentName, String targetContentLanguage, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1939    {
1940        return copyProgramItem(srcContent, targetContentName, targetContentLanguage, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1941    }
1942    
1943    /**
1944     * Copy a {@link CoursePart}
1945     * @param srcContent The course part to copy
1946     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1947     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1948     * @param initWorkflowActionId The initial workflow action id
1949     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1950     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1951     * @param copiedContents the initial contents with their copied content
1952     * @param <C> The modifiable content return type
1953     * @return The created content
1954     * @throws AmetysRepositoryException If an error occurred
1955     * @throws WorkflowException If an error occurred
1956     */
1957    public <C extends ModifiableContent> C copyCoursePart(CoursePart srcContent, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1958    {
1959        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1960    }
1961    
1962    /**
1963     * Copy a {@link ProgramItem}
1964     * @param srcContent The program item to copy
1965     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1966     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1967     * @param initWorkflowActionId The initial workflow action id
1968     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1969     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1970     * @param copiedContents the initial contents with their copied content
1971     * @param <C> The modifiable content return type
1972     * @return The created content
1973     * @throws AmetysRepositoryException If an error occurred
1974     * @throws WorkflowException If an error occurred
1975     */
1976    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1977    {
1978        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1979    }
1980    
1981    /**
1982     * Copy a {@link ProgramItem}. Also copy the synchronization metadata (status and alternative value)
1983     * @param srcContent The program item to copy
1984     * @param catalog The catalog
1985     * @param code The odf content code
1986     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1987     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
1988     * @param initWorkflowActionId The initial workflow action id
1989     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1990     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1991     * @param copiedContents the initial contents with their copied content
1992     * @param <C> The modifiable content return type
1993     * @return The created content
1994     * @throws AmetysRepositoryException If an error occurred
1995     * @throws WorkflowException If an error occurred
1996     */
1997    @SuppressWarnings("unchecked")
1998    private <C extends ModifiableContent> C _copyODFContent(Content srcContent, String catalog, String code, String targetContentName, String targetContentLanguage, int initWorkflowActionId, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1999    {
2000        String computedTargetLanguage = targetContentLanguage;
2001        if (computedTargetLanguage == null)
2002        {
2003            computedTargetLanguage = srcContent.getLanguage();
2004        }
2005        
2006        String computeTargetName = targetContentName;
2007        if (computeTargetName == null)
2008        {
2009            // Compute content name from source content and requested language
2010            computeTargetName = srcContent.getName() + (targetContentLanguage != null && !targetContentLanguage.equals(srcContent.getName()) ? "-" + targetContentLanguage : "");
2011        }
2012        
2013        String computeTargetCatalog = targetCatalog;
2014        if (computeTargetCatalog == null)
2015        {
2016            computeTargetCatalog = catalog;
2017        }
2018        
2019        String principalContentType = srcContent.getTypes()[0];
2020        ModifiableContent createdContent = getODFContent(principalContentType, code, computeTargetCatalog, computedTargetLanguage);
2021        if (createdContent != null)
2022        {
2023            getLogger().info("A program item already exists with the same type, code, catalog and language [{}, {}, {}, {}]", principalContentType, code, computeTargetCatalog, computedTargetLanguage);
2024        }
2025        else
2026        {
2027            // Copy content without notifying observers (done later) and copying ACL
2028            DataContext context = RepositoryDataContext.newInstance()
2029                                                       .withExternalMetadataInCopy(true);
2030            createdContent = ((DefaultContent) srcContent).copyTo(getRootContent(true), computeTargetName, targetContentLanguage, initWorkflowActionId, false, true, false, true, context);
2031            
2032            if (fullCopy)
2033            {
2034                _cleanContentMetadata(createdContent);
2035                
2036                if (targetCatalog != null)
2037                {
2038                    if (createdContent instanceof ProgramItem programItem)
2039                    {
2040                        programItem.setCatalog(targetCatalog);
2041                    }
2042                    else if (createdContent instanceof CoursePart coursePart)
2043                    {
2044                        coursePart.setCatalog(targetCatalog);
2045                    }
2046                    
2047                }
2048                
2049                if (srcContent instanceof ProgramItem programItem)
2050                {
2051                    copyProgramItemStructure(programItem, createdContent, computedTargetLanguage, initWorkflowActionId, computeTargetCatalog, copiedContents);
2052                }
2053                
2054                _extractOutgoingReferences(createdContent);
2055                
2056                createdContent.saveChanges();
2057            }
2058            
2059            // Notify observers after all structure has been copied
2060            _contentDAO.notifyContentCopied(createdContent, false);
2061            
2062            copiedContents.put(srcContent, createdContent);
2063        }
2064        
2065        return (C) createdContent;
2066    }
2067    
2068    /**
2069     * Copy the structure of a {@link ProgramItem}
2070     * @param srcContent the content to copy
2071     * @param targetContent the target content
2072     * @param targetContentLanguage The name of content to created. Can be null. If null, the language of target content will be the same as source object.
2073     * @param initWorkflowActionId The initial workflow action id
2074     * @param targetCatalogName The target catalog. Can be null. The target catalog will be the catalog of the source object.
2075     * @param copiedContents the initial contents with their copied content
2076     * @throws AmetysRepositoryException If an error occurred during copy
2077     * @throws WorkflowException If an error occurred during copy
2078     */
2079    protected void copyProgramItemStructure(ProgramItem srcContent, ModifiableContent targetContent, String targetContentLanguage, int initWorkflowActionId, String targetCatalogName, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
2080    {
2081        List<ProgramItem> srcChildContents = new ArrayList<>();
2082        Map<Pair<String, String>, List<String>> values = new HashMap<>();
2083        
2084        String childMetadataPath = null;
2085        String parentMetadataPath = null;
2086        
2087        if (srcContent instanceof TraversableProgramPart programPart)
2088        {
2089            childMetadataPath = TraversableProgramPart.CHILD_PROGRAM_PARTS;
2090            parentMetadataPath = ProgramPart.PARENT_PROGRAM_PARTS;
2091            srcChildContents.addAll(programPart.getProgramPartChildren());
2092        }
2093        else if (srcContent instanceof CourseList courseList)
2094        {
2095            childMetadataPath = CourseList.CHILD_COURSES;
2096            parentMetadataPath = Course.PARENT_COURSE_LISTS;
2097            srcChildContents.addAll(courseList.getCourses());
2098        }
2099        else if (srcContent instanceof Course course)
2100        {
2101            childMetadataPath = Course.CHILD_COURSE_LISTS;
2102            parentMetadataPath = CourseList.PARENT_COURSES;
2103            srcChildContents.addAll(course.getCourseLists());
2104
2105            List<String> refCoursePartIds = new ArrayList<>();
2106            for (CoursePart srcChildContent : course.getCourseParts())
2107            {
2108                CoursePart targetChildContent = copyCoursePart(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
2109                refCoursePartIds.add(targetChildContent.getId());
2110            }
2111            _addFormValues(values, Course.CHILD_COURSE_PARTS, CoursePart.PARENT_COURSES, refCoursePartIds);
2112        }
2113
2114        List<String> refChildIds = new ArrayList<>();
2115        for (ProgramItem srcChildContent : srcChildContents)
2116        {
2117            ProgramItem targetChildContent = copyProgramItem(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
2118            refChildIds.add(targetChildContent.getId());
2119        }
2120
2121        _addFormValues(values, childMetadataPath, parentMetadataPath, refChildIds);
2122
2123        _editChildRelation((ModifiableWorkflowAwareContent) targetContent, values);
2124    }
2125    
2126    private void _addFormValues(Map<Pair<String, String>, List<String>> values, String childMetadataPath, String parentMetadataPath, List<String> refChildIds)
2127    {
2128        if (!refChildIds.isEmpty())
2129        {
2130            values.put(Pair.of(childMetadataPath, parentMetadataPath), refChildIds);
2131        }
2132    }
2133    
2134    private void _editChildRelation(ModifiableWorkflowAwareContent parentContent, Map<Pair<String, String>, List<String>> values) throws AmetysRepositoryException
2135    {
2136        if (!values.isEmpty())
2137        {
2138            for (Map.Entry<Pair<String, String>, List<String>> entry : values.entrySet())
2139            {
2140                String childMetadataName = entry.getKey().getLeft();
2141                String parentMetadataName = entry.getKey().getRight();
2142                List<String> childContents = entry.getValue();
2143                
2144                parentContent.setValue(childMetadataName, childContents.toArray(new String[childContents.size()]));
2145                
2146                for (String childContentId : childContents)
2147                {
2148                    ModifiableContent content = _resolver.resolveById(childContentId);
2149                    String[] parentContentIds = ContentDataHelper.getContentIdsArrayFromMultipleContentData(content, parentMetadataName);
2150                    content.setValue(parentMetadataName, ArrayUtils.add(parentContentIds, parentContent.getId()));
2151                    content.saveChanges();
2152                }
2153            }
2154        }
2155    }
2156    
2157    /**
2158     * Clean the CONTENT metadata created after a copy but whose values reference the initial content' structure
2159     * @param createdContent The created content to clean
2160     */
2161    protected void _cleanContentMetadata(ModifiableContent createdContent)
2162    {
2163        if (createdContent instanceof ProgramPart)
2164        {
2165            _removeFullValue(createdContent, ProgramPart.PARENT_PROGRAM_PARTS);
2166        }
2167        
2168        if (createdContent instanceof TraversableProgramPart)
2169        {
2170            _removeFullValue(createdContent, TraversableProgramPart.CHILD_PROGRAM_PARTS);
2171        }
2172        
2173        if (createdContent instanceof CourseList)
2174        {
2175            _removeFullValue(createdContent, CourseList.CHILD_COURSES);
2176            _removeFullValue(createdContent, CourseList.PARENT_COURSES);
2177        }
2178        
2179        if (createdContent instanceof Course)
2180        {
2181            _removeFullValue(createdContent, Course.CHILD_COURSE_LISTS);
2182            _removeFullValue(createdContent, Course.PARENT_COURSE_LISTS);
2183            _removeFullValue(createdContent, Course.CHILD_COURSE_PARTS);
2184        }
2185        
2186        if (createdContent instanceof CoursePart)
2187        {
2188            _removeFullValue(createdContent, CoursePart.PARENT_COURSES);
2189        }
2190    }
2191    
2192    private void _removeFullValue(ModifiableContent content, String attributeName)
2193    {
2194        content.removeValue(attributeName);
2195        content.removeExternalizableMetadataIfExists(attributeName);
2196    }
2197    
2198    private void _extractOutgoingReferences(ModifiableContent content)
2199    {
2200        Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content);
2201        content.setOutgoingReferences(outgoingReferencesByPath);
2202    }
2203    
2204    /**
2205     * Switch the ametys object to Live version if it has one
2206     * @param ao the Ametys object
2207     * @throws NoLiveVersionException if the content has no live version
2208     */
2209    public void switchToLiveVersion(DefaultAmetysObject ao) throws NoLiveVersionException
2210    {
2211        // Switch to the Live label if exists
2212        String[] allLabels = ao.getAllLabels();
2213        String[] currentLabels = ao.getLabels();
2214        
2215        boolean hasLiveVersion = Arrays.asList(allLabels).contains(CmsConstants.LIVE_LABEL);
2216        boolean currentVersionIsLive = Arrays.asList(currentLabels).contains(CmsConstants.LIVE_LABEL);
2217        
2218        if (hasLiveVersion && !currentVersionIsLive)
2219        {
2220            ao.switchToLabel(CmsConstants.LIVE_LABEL);
2221        }
2222        else if (!hasLiveVersion)
2223        {
2224            throw new NoLiveVersionException("The ametys object '" + ao.getId() + "' has no live version");
2225        }
2226    }
2227    
2228    /**
2229     * Switch to Live version if is required
2230     * @param ao the Ametys object
2231     * @throws NoLiveVersionException if the Live version is required but not exist
2232     */
2233    public void switchToLiveVersionIfNeeded(DefaultAmetysObject ao) throws NoLiveVersionException
2234    {
2235        Request request = _getRequest();
2236        if (request != null && request.getAttribute(REQUEST_ATTRIBUTE_VALID_LABEL) != null)
2237        {
2238            switchToLiveVersion(ao);
2239        }
2240    }
2241    
2242    /**
2243     * Count the hours accumulation in the {@link ProgramItem}
2244     * @param programItem The program item on which we compute the total number of hours
2245     * @return The hours accumulation
2246     */
2247    public Double getCumulatedHours(ProgramItem programItem)
2248    {
2249        // Ignore optional course list and avoid useless expensive calls
2250        if (programItem instanceof CourseList courseList && ChoiceType.OPTIONAL.equals(courseList.getType()))
2251        {
2252            return 0.0;
2253        }
2254
2255        List<ProgramItem> children = getChildProgramItems(programItem);
2256
2257        Double coef = 1.0;
2258        Double countNbHours = 0.0;
2259
2260        // If the program item is a course list, compute the coef (mandatory: 1, optional: 0, optional: min / total)
2261        if (programItem instanceof CourseList courseList)
2262        {
2263            // If there is no children, compute the coef is useless
2264            // Also choice list can throw an exception while dividing by zero
2265            if (children.isEmpty())
2266            {
2267                return 0.0;
2268            }
2269            
2270            switch (courseList.getType())
2271            {
2272                case CHOICE:
2273                    // Apply the average of number of EC from children multiply by the minimum ELP to select
2274                    coef = ((double) courseList.getMinNumberOfCourses()) / children.size();
2275                    break;
2276                case MANDATORY:
2277                default:
2278                    // Add all ECTS from children
2279                    break;
2280            }
2281        }
2282
2283        // If it's a course and we have a value for the number of hours
2284        // Then get the value
2285        if (programItem instanceof Course course && course.hasValue(Course.NUMBER_OF_HOURS))
2286        {
2287            countNbHours += course.<Double>getValue(Course.NUMBER_OF_HOURS);
2288        }
2289        // Else if there are program item children on the item
2290        // Then compute on children
2291        else if (children.size() > 0)
2292        {
2293            for (ProgramItem child : children)
2294            {
2295                countNbHours += getCumulatedHours(child);
2296            }
2297        }
2298        // Else, it's a course but there is no value for the number of hours and we don't have program item children
2299        // Then compute on course parts
2300        else if (programItem instanceof Course course)
2301        {
2302            countNbHours += course.getCourseParts()
2303                .stream()
2304                .mapToDouble(CoursePart::getNumberOfHours)
2305                .sum();
2306        }
2307        
2308        return coef * countNbHours;
2309    }
2310    
2311    /**
2312     * Get the request
2313     * @return the request
2314     */
2315    protected Request _getRequest()
2316    {
2317        return ContextHelper.getRequest(_context);
2318    }
2319    
2320    /**
2321     * Get the first orgunit matching the given UAI code
2322     * @param uaiCode the UAI code
2323     * @return the orgunit or null if not found
2324     */
2325    public OrgUnit getOrgUnitByUAICode(String uaiCode)
2326    {
2327        Expression expr = new AndExpression(
2328                new ContentTypeExpression(Operator.EQ, OrgUnitFactory.ORGUNIT_CONTENT_TYPE),
2329                new StringExpression(OrgUnit.CODE_UAI, Operator.EQ, uaiCode)
2330        );
2331        
2332        String xPathQuery = QueryHelper.getXPathQuery(null, OrgUnitFactory.ORGUNIT_NODETYPE, expr);
2333        AmetysObjectIterable<OrgUnit> orgUnits = _resolver.query(xPathQuery);
2334        
2335        return orgUnits.stream()
2336            .findFirst()
2337            .orElse(null);
2338    }
2339    
2340    /**
2341     * Get the repeater entries filtered by path, it takes care of "common" attribute if exists and find itself the educational-path attribute.
2342     * @param repeater The repeater to filter
2343     * @param educationalPaths List of full educational paths
2344     * @return a {@link Stream} of filtered repeater entries
2345     */
2346    public Stream<? extends RepeaterEntry> getRepeaterEntriesByPath(Repeater repeater, List<EducationalPath> educationalPaths)
2347    {
2348        // Build the filter to apply on repeater entries
2349        Predicate<RepeaterEntry> filterRepeaterEntries =  _buildRepeaterEntryByPathPredicate(repeater, educationalPaths);
2350        
2351        // If predicate is null, an error has been logged
2352        if (filterRepeaterEntries == null)
2353        {
2354            return Stream.empty();
2355        }
2356        
2357        // For each entry, check if the entry is common (if attribute exists) or the path correspond to one of the retrieved full educational paths
2358        return repeater.getEntries()
2359            .stream()
2360            .filter(filterRepeaterEntries);
2361    }
2362    
2363    private Predicate<RepeaterEntry> _buildRepeaterEntryByPathPredicate(Repeater repeater, List<EducationalPath> educationalPaths)
2364    {
2365        RepeaterDefinition repeaterModel = repeater.getModel();
2366        
2367        Predicate<ModelItem> keepSinglePaths = modelItem -> {
2368            // Retrieve the path of the parent to check if it is a multiple attribute (remove the repeater path at the beginning of the path, because it would always be multiple otherwise)
2369            // The parent cannot be null because we know that the repeater is the parent
2370            String path = StringUtils.substringAfter(modelItem.getParent().getPath(), repeaterModel.getPath() + ModelItem.ITEM_PATH_SEPARATOR);
2371            
2372            // If the path is empty, that means the attribute is at the first level of the repeater, so it can be used
2373            return StringUtils.isEmpty(path) || !DataHolderHelper.isMultiple(repeaterModel, path);
2374        };
2375        
2376        List<ModelItem> educationalPathModelItem = ModelHelper.findModelItemsByType(repeaterModel, EducationalPathElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID)
2377                               .stream()
2378                               // If the item containing the path is multiple, we cannot use the path to filter the repeater entries, so exclude them
2379                               .filter(keepSinglePaths)
2380                               .toList();
2381        
2382        if (educationalPathModelItem.size() != 1)
2383        {
2384            getLogger().error("Unable to determine repeater entry matching an education path. No attribute or several attributes of type '{}' found.", EducationalPathElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID, repeaterModel.getPath());
2385            return null;
2386        }
2387        
2388        // Prepare the predicate for educational path attribute
2389        String pathAttributeName = educationalPathModelItem.get(0).getName();
2390        Predicate<RepeaterEntry> repeaterEntriesFilter = e -> educationalPaths.contains(e.getValueOrDefault(pathAttributeName, null));
2391        
2392        // Complete predicate with common attribute if exists
2393        if (repeater.getModel().hasModelItem("common"))
2394        {
2395            return ((Predicate<RepeaterEntry>) e -> e.getValueOrDefault("common")).or(repeaterEntriesFilter);
2396        }
2397        
2398        return repeaterEntriesFilter;
2399    }
2400    
2401    /**
2402     * Determine if the content is a container of nature equals to "annee"
2403     * @param content The content
2404     * @return <code>true</code> if the current content item is a container of nature equals to "annee"
2405     */
2406    public boolean isContainerOfTypeYear(Content content)
2407    {
2408        return content instanceof Container container && isContainerOfTypeYear(container);
2409    }
2410    
2411    /**
2412     * Determine if the container nature equals to "annee"
2413     * @param containerId The container id
2414     * @return <code>true</code> if the current container nature equals to "annee"
2415     */
2416    @Callable (rights = Callable.NO_CHECK_REQUIRED)
2417    public boolean isContainerOfTypeYear(String containerId)
2418    {
2419        Container container = _resolver.resolveById(containerId);
2420        return isContainerOfTypeYear(container);
2421    }
2422    
2423    /**
2424     * Determine if the container nature equals to "annee"
2425     * @param container The container
2426     * @return <code>true</code> if the current container nature equals to "annee"
2427     */
2428    public boolean isContainerOfTypeYear(Container container)
2429    {
2430        return getYearId()
2431                .map(id -> Strings.CS.equals(id, container.getNature()))
2432                .orElse(false);
2433    }
2434    
2435    /**
2436     * Get the year container nature identifier.
2437     * @return an {@link Optional} of the year identifier
2438     */
2439    public synchronized Optional<String> getYearId()
2440    {
2441        if (_yearId.isEmpty())
2442        {
2443            _yearId = Optional.of(_refTableHelper)
2444                .map(rth -> rth.getItemFromCode(OdfReferenceTableHelper.CONTAINER_NATURE, "annee"))
2445                .map(OdfReferenceTableEntry::getId)
2446                .filter(StringUtils::isNotBlank);
2447        }
2448        return _yearId;
2449    }
2450    
2451    /**
2452     * Get all the years of a program part, search in children.
2453     * @param programPart The program part
2454     * @return A set of {@link Container} with the year nature.
2455     */
2456    public Set<Container> getYears(TraversableProgramPart programPart)
2457    {
2458        Set<Container> years = new LinkedHashSet<>();
2459        
2460        if (programPart instanceof Container container && isContainerOfTypeYear(container))
2461        {
2462            years.add(container);
2463        }
2464        
2465        programPart.getProgramPartChildren()
2466                   .stream()
2467                   .filter(TraversableProgramPart.class::isInstance)
2468                   .map(TraversableProgramPart.class::cast)
2469                   .map(this::getYears)
2470                   .flatMap(Set::stream)
2471                   .forEach(years::add);
2472        
2473        return years;
2474    }
2475    
2476    /**
2477     * Filter the program item to keep only container with the given nature.
2478     * @param programItem The program item
2479     * @param natureId The container nature identifier
2480     * @return <code>true</code> if it is a container of the given nature, <code>false</code> otherwise
2481     */
2482    public boolean isContainerOfNature(ProgramItem programItem, String natureId)
2483    {
2484        return Optional.of(programItem)
2485            .filter(Container.class::isInstance)
2486            .map(Container.class::cast)
2487            .map(Container::getNature)
2488            .map(natureId::equals)
2489            .orElse(false);
2490    }
2491}