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