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