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