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