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     * Determines if a educational path is valid ODF path
1294     * @param path the educational path
1295     * @return <code>true</code> if path represents a valid ODF path
1296     */
1297    public boolean isValid(EducationalPath path)
1298    {
1299        // Get leaf program item of this educational path
1300        List<String> programItemIds = path.getProgramItemIds();
1301        String leafProgramItemId = programItemIds.get(programItemIds.size() - 1);
1302        if (_resolver.hasAmetysObjectForId(leafProgramItemId))
1303        {
1304            ProgramItem leafProgramItem = _resolver.resolveById(leafProgramItemId);
1305            return isValid(path, leafProgramItem, false);
1306        }
1307        return false;
1308    }
1309    
1310    
1311    /**
1312     * Determines if the given educational path is a valid path for the program item
1313     * @param path the educational path. The path must include the program item itself
1314     * @param programItem the program item
1315     * @param ignoreOrphanPath set to true to ignore paths that is not part of a Program
1316     * @return <code>true</code> if path is valid for the given program item
1317     */
1318    public boolean isValid(EducationalPath path, ProgramItem programItem, boolean ignoreOrphanPath)
1319    {
1320        return getEducationalPaths(programItem, true, ignoreOrphanPath).contains(path);
1321    }
1322    
1323    /**
1324     * Get the full path to program item for highest ancestors. The path includes this final item.
1325     * @param programItem the program item
1326     * @return a list for each highest ancestors found. Each item of the list contains the program items to the path to this program item.
1327     */
1328    protected List<List<ProgramItem>> getPathOfAncestors(ProgramItem programItem)
1329    {
1330        Cache<ProgramItem, List<List<ProgramItem>>> cache = _cacheManager.get(__ANCESTORS_CACHE);
1331        
1332        return cache.get(programItem, item -> {
1333            List<ProgramItem> parentProgramItems = getParentProgramItems(item);
1334            
1335            // There is no more parents, the only path is the item itself
1336            if (parentProgramItems.isEmpty())
1337            {
1338                return List.of(List.of(item));
1339            }
1340            
1341            List<List<ProgramItem>> ancestors = new ArrayList<>();
1342            
1343            // Compute the path for each parent
1344            for (ProgramItem parentProgramItem : parentProgramItems)
1345            {
1346                for (List<ProgramItem> ancestorPaths : getPathOfAncestors(parentProgramItem))
1347                {
1348                    List<ProgramItem> ancestorPathsCopy = new ArrayList<>(ancestorPaths);
1349                    ancestorPathsCopy.add(item);
1350                    // Add an immutable list to avoid unvoluntary modifications in cache
1351                    ancestors.add(Collections.unmodifiableList(ancestorPathsCopy));
1352                }
1353            }
1354
1355            // Add an immutable list to avoid unvoluntary modifications in cache
1356            return Collections.unmodifiableList(ancestors);
1357        });
1358    }
1359    
1360    /**
1361     * Get the enumeration of educational paths for a program item for highest ancestors. The paths does not includes this final item.
1362     * @param programItemId the id of program item
1363     * @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)
1364     */
1365    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1366    public List<Map<String, String>> getEducationalPathsEnumeration(String programItemId)
1367    {
1368        List<Map<String, String>> paths = new ArrayList<>();
1369        
1370        ProgramItem programItem = _resolver.resolveById(programItemId);
1371        
1372        getEducationalPaths(programItem)
1373            .stream()
1374            .forEach(p -> paths.add(Map.of("id", p.toString(), "title", getEducationalPathAsString(p, c -> ((Content) c).getTitle(), " > "))));
1375        
1376        return paths;
1377    }
1378    
1379    
1380    /**
1381     * Get the path of a {@link ProgramItem} into a {@link Program}<br>
1382     * The path is construct with the contents' names and the used separator is '/'.
1383     * @param programItemId The id of the program item
1384     * @param programId The id of program. Can not be null.
1385     * @return the path into the parent program or null if the item is not part of this program.
1386     */
1387    @Callable
1388    public String getPathInProgram (String programItemId, String programId)
1389    {
1390        ProgramItem item = _resolver.resolveById(programItemId);
1391        Program program = _resolver.resolveById(programId);
1392        
1393        return getPathInProgram(item, program);
1394    }
1395    
1396    /**
1397     * Get the path of a ODF content into a {@link Program}.<br>
1398     * The path is construct with the contents' names and the used separator is '/'.
1399     * @param item The program item
1400     * @param parentProgram The parent root (sub)program. Can not be null.
1401     * @return the path from the parent program
1402     */
1403    public String getPathInProgram (ProgramItem item, Program parentProgram)
1404    {
1405        if (item instanceof Program)
1406        {
1407            // The program item is already the program it self or another program
1408            return item.equals(parentProgram) ? "" : null;
1409        }
1410        
1411        List<EducationalPath> paths = getEducationalPaths(item, true, true);
1412        
1413        for (EducationalPath path : paths)
1414        {
1415            if (path.getProgramItemIds().contains(parentProgram.getId()))
1416            {
1417                // Find a path that match the given parent program
1418                Stream<ProgramItem> resolvedPath = path.resolveProgramItems(_resolver);
1419                return resolvedPath.map(ProgramItem::getName).collect(Collectors.joining("/"));
1420            }
1421        }
1422        
1423        return null;
1424    }
1425    
1426    /**
1427     * Get the path of a {@link Course} or a {@link CourseList} into a {@link Course}<br>
1428     * The path is construct with the contents' names and the used separator is '/'.
1429     * @param contentId The id of the content
1430     * @param parentCourseId The id of parent course. Can not be null.
1431     * @return the path into the parent course or null if the item is not part of this course.
1432     */
1433    @Callable
1434    public String getPathInCourse (String contentId, String parentCourseId)
1435    {
1436        Content content = _resolver.resolveById(contentId);
1437        Course parentCourse = _resolver.resolveById(parentCourseId);
1438        
1439        return getPathInCourse(content, parentCourse);
1440    }
1441    
1442    /**
1443     * Get the path of a {@link Course}  or a {@link CourseList} into a {@link Course}<br>
1444     * The path is construct with the contents' names and the used separator is '/'.
1445     * @param courseOrList The course or the course list
1446     * @param parentCourse The parent course. Can not be null.
1447     * @return the path into the parent course or null if the item is not part of this course.
1448     */
1449    public String getPathInCourse(Content courseOrList, Course parentCourse)
1450    {
1451        if (courseOrList.equals(parentCourse))
1452        {
1453            return "";
1454        }
1455        
1456        String path = _getPathInCourse(courseOrList, parentCourse);
1457        
1458        return path;
1459    }
1460    
1461    private String _getPathInCourse(Content content, Content parentContent)
1462    {
1463        if (content.equals(parentContent))
1464        {
1465            return content.getName();
1466        }
1467
1468        List<? extends Content> parents;
1469        
1470        if (content instanceof Course course)
1471        {
1472            parents = course.getParentCourseLists();
1473        }
1474        else if (content instanceof CourseList courseList)
1475        {
1476            parents = courseList.getParentCourses();
1477        }
1478        else
1479        {
1480            throw new IllegalStateException();
1481        }
1482        
1483        for (Content parent : parents)
1484        {
1485            String path = _getPathInCourse(parent, parentContent);
1486            if (path != null)
1487            {
1488                return path + '/' + content.getName();
1489            }
1490        }
1491        return null;
1492    }
1493    
1494    /**
1495     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit id.<br>
1496     * The path is construct with the contents' names and the used separator is '/'.
1497     * @param orgUnitId The id of the orgunit
1498     * @param rootOrgUnitId The root orgunit id
1499     * @return the path into the parent program or null if the item is not part of this program.
1500     */
1501    @Callable
1502    public String getOrgUnitPath(String orgUnitId, String rootOrgUnitId)
1503    {
1504        OrgUnit rootOU = null;
1505        if (StringUtils.isNotBlank(rootOrgUnitId))
1506        {
1507            rootOU = _resolver.resolveById(rootOrgUnitId);
1508        }
1509        else
1510        {
1511            rootOU = _ouRootProvider.getRoot();
1512        }
1513        
1514        if (orgUnitId.equals(rootOU.getId()))
1515        {
1516            // The orgunit is already the root orgunit
1517            return rootOU.getName();
1518        }
1519        
1520        OrgUnit ou = _resolver.resolveById(orgUnitId);
1521        
1522        List<String> paths = new ArrayList<>();
1523        paths.add(ou.getName());
1524        
1525        OrgUnit parent = ou.getParentOrgUnit();
1526        while (parent != null && !parent.getId().equals(rootOU.getId()))
1527        {
1528            paths.add(parent.getName());
1529            parent = parent.getParentOrgUnit();
1530        }
1531        
1532        if (parent != null)
1533        {
1534            paths.add(rootOU.getName());
1535            Collections.reverse(paths);
1536            return StringUtils.join(paths, "/");
1537        }
1538        
1539        return null;
1540    }
1541    
1542    /**
1543     * Get the hierarchical path of a {@link OrgUnit} from the root orgunit.<br>
1544     * The path is construct with the contents' names and the used separator is '/'.
1545     * @param orgUnitId The id of the orgunit
1546     * @return the path into the parent program or null if the item is not part of this program.
1547     */
1548    @Callable
1549    public String getOrgUnitPath(String orgUnitId)
1550    {
1551        return getOrgUnitPath(orgUnitId, null);
1552    }
1553    
1554    /**
1555     * Return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1556     * @param part The program part
1557     * @param parentId The ancestor id
1558     * @return true if the given {@link ProgramPart} has in its hierarchy a parent of given id
1559     */
1560    public boolean hasAncestor (ProgramPart part, String parentId)
1561    {
1562        List<ProgramPart> parents = part.getProgramPartParents();
1563        
1564        for (ProgramPart parent : parents)
1565        {
1566            if (parent.getId().equals(parentId))
1567            {
1568                return true;
1569            }
1570            else if (hasAncestor(parent, parentId))
1571            {
1572                return true;
1573            }
1574        }
1575        
1576        return false;
1577    }
1578    
1579    /**
1580     * Determines if a program item is shared
1581     * @param programItem the program item
1582     * @return true if the program item is shared
1583     */
1584    public boolean isShared(ProgramItem programItem)
1585    {
1586        List<ProgramItem> parents = getParentProgramItems(programItem);
1587        if (parents.size() > 1)
1588        {
1589            return true;
1590        }
1591        else
1592        {
1593            return parents.isEmpty() ? false : isShared(parents.get(0));
1594        }
1595    }
1596    
1597    /**
1598     * Check if a relation can be establish between two ODF contents
1599     * @param srcContent The source content (copied or moved)
1600     * @param targetContent The target content
1601     * @param errors The list of error messages
1602     * @param contextualParameters the contextual parameters
1603     * @return true if the relation is valid, false otherwise
1604     */
1605    public boolean isRelationCompatible(Content srcContent, Content targetContent, List<I18nizableText> errors, Map<String, Object> contextualParameters)
1606    {
1607        boolean isCompatible = true;
1608        
1609        if (targetContent instanceof ProgramItem || targetContent instanceof OrgUnit)
1610        {
1611            if (!_isContentTypeCompatible(srcContent, targetContent))
1612            {
1613                // Invalid relations between content types
1614                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CONTENT_TYPES", _getContentParameters(srcContent, targetContent)));
1615                isCompatible = false;
1616            }
1617            else if (!_isCatalogCompatible(srcContent, targetContent))
1618            {
1619                // Catalog is invalid
1620                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_CATALOG", _getContentParameters(srcContent, targetContent)));
1621                isCompatible = false;
1622            }
1623            else if (!_isLanguageCompatible(srcContent, targetContent))
1624            {
1625                // Language is invalid
1626                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_LANGUAGE", _getContentParameters(srcContent, targetContent)));
1627                isCompatible = false;
1628            }
1629            else if (!_areShareableFieldsCompatibles(srcContent, targetContent, contextualParameters))
1630            {
1631                // Shareable fields don't match
1632                errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_SHAREABLE_COURSE", _getContentParameters(srcContent, targetContent)));
1633                isCompatible = false;
1634            }
1635        }
1636        else if (srcContent instanceof ProgramItem || srcContent instanceof OrgUnit)
1637        {
1638            // If the target isn't ODF related but the source is, the relation is not compatible.
1639            errors.add(new I18nizableText("plugin.odf", "PLUGINS_ODF_RELATIONS_SETCONTENTATTRIBUTE_REFERENCE_ERROR_NO_PROGRAM_ITEM", _getContentParameters(srcContent, targetContent)));
1640            isCompatible = false;
1641        }
1642        
1643        return isCompatible;
1644    }
1645    
1646    /**
1647     * Get the name of attribute holding the relation between a parent content and its children
1648     * @param parentProgramItem the parent content
1649     * @param childProgramItem the child content
1650     * @return the name of attribute the child relation
1651     */
1652    public String getDescendantRelationAttributeName(ProgramItem parentProgramItem, ProgramItem childProgramItem)
1653    {
1654        if (parentProgramItem instanceof CourseList && childProgramItem instanceof Course)
1655        {
1656            return CourseList.CHILD_COURSES;
1657        }
1658        else if (parentProgramItem instanceof Course && childProgramItem instanceof CourseList)
1659        {
1660            return Course.CHILD_COURSE_LISTS;
1661        }
1662        else if (parentProgramItem instanceof Course && childProgramItem instanceof CoursePart)
1663        {
1664            return Course.CHILD_COURSE_PARTS;
1665        }
1666        else if (parentProgramItem instanceof TraversableProgramPart && childProgramItem instanceof ProgramPart)
1667        {
1668            return TraversableProgramPart.CHILD_PROGRAM_PARTS;
1669        }
1670        
1671        return null;
1672    }
1673    
1674    private boolean _isCourseAlreadyBelongToCourseList(Course course, CourseList courseList)
1675    {
1676        return courseList.getCourses().contains(course);
1677    }
1678    
1679    private boolean _isContentTypeCompatible(Content srcContent, Content targetContent)
1680    {
1681        if (srcContent instanceof Container || srcContent instanceof SubProgram)
1682        {
1683            return targetContent instanceof AbstractTraversableProgramPart;
1684        }
1685        else if (srcContent instanceof CourseList)
1686        {
1687            return targetContent instanceof CourseListContainer;
1688        }
1689        else if (srcContent instanceof Course)
1690        {
1691            return targetContent instanceof CourseList;
1692        }
1693        else if (srcContent instanceof OrgUnit)
1694        {
1695            return targetContent instanceof OrgUnit;
1696        }
1697        
1698        return false;
1699    }
1700    
1701    private boolean _isCatalogCompatible(Content srcContent, Content targetContent)
1702    {
1703        if (srcContent instanceof ProgramItem srcProgramItem && targetContent instanceof ProgramItem targetProgramItem)
1704        {
1705            return srcProgramItem.getCatalog().equals(targetProgramItem.getCatalog());
1706        }
1707        return true;
1708    }
1709    
1710    private boolean _isLanguageCompatible(Content srcContent, Content targetContent)
1711    {
1712        return srcContent.getLanguage().equals(targetContent.getLanguage());
1713    }
1714    
1715    private boolean _areShareableFieldsCompatibles(Content srcContent, Content targetContent, Map<String, Object> contextualParameters)
1716    {
1717        // We check shareable fields only if the course content is not created (or created by copy) and not moved
1718        if (srcContent instanceof Course srcCourse
1719                && targetContent instanceof CourseList targetCourseList
1720                && _shareableCourseHelper.handleShareableCourse()
1721                && !"create".equals(contextualParameters.get("mode"))
1722                && !"copy".equals(contextualParameters.get("mode"))
1723                && !"move".equals(contextualParameters.get("mode"))
1724                // In this case, it means that we try to change the position of the course in the courseList, so don't check shareable fields
1725                && !_isCourseAlreadyBelongToCourseList(srcCourse, targetCourseList))
1726        {
1727            return _shareableCourseHelper.isShareableFieldsMatch(srcCourse, targetCourseList);
1728        }
1729        
1730        return true;
1731    }
1732    
1733    private List<String> _getContentParameters(Content srcContent, Content targetContent)
1734    {
1735        List<String> parameters = new ArrayList<>();
1736        parameters.add(srcContent.getTitle());
1737        parameters.add(srcContent.getId());
1738        parameters.add(targetContent.getTitle());
1739        parameters.add(targetContent.getId());
1740        return parameters;
1741    }
1742    /**
1743     * Copy a {@link ProgramItem}
1744     * @param srcContent The program item to copy
1745     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1746     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1747     * @param copiedContents the initial contents with their copied content
1748     * @return The created content
1749     * @param <C> The modifiable content return type
1750     * @throws AmetysRepositoryException If an error occurred during copy
1751     * @throws WorkflowException If an error occurred during copy
1752     */
1753    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1754    {
1755        return copyProgramItem(srcContent, null, null, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1756    }
1757    
1758    /**
1759     * Copy a {@link ProgramItem}
1760     * @param srcContent The program item to copy
1761     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1762     * @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.
1763     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1764     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1765     * @param copiedContents the initial contents with their copied content
1766     * @param <C> The modifiable content return type
1767     * @return The created content
1768     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1769     * @throws AmetysRepositoryException If an error occurred
1770     * @throws WorkflowException If an error occurred
1771     */
1772    public <C extends ModifiableContent> C copyProgramItem(ProgramItem srcContent, String targetContentName, String targetContentLanguage, String targetCatalog, boolean fullCopy, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1773    {
1774        return copyProgramItem(srcContent, targetContentName, targetContentLanguage, __INITIAL_WORKFLOW_ACTION_ID, targetCatalog, fullCopy, copiedContents);
1775    }
1776    
1777    /**
1778     * Copy a {@link CoursePart}
1779     * @param srcContent The course part to copy
1780     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1781     * @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.
1782     * @param initWorkflowActionId The initial workflow action id
1783     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1784     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1785     * @param copiedContents the initial contents with their copied content
1786     * @param <C> The modifiable content return type
1787     * @return The created content
1788     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1789     * @throws AmetysRepositoryException If an error occurred
1790     * @throws WorkflowException If an error occurred
1791     */
1792    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
1793    {
1794        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1795    }
1796    
1797    /**
1798     * Copy a {@link ProgramItem}
1799     * @param srcContent The program item to copy
1800     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1801     * @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.
1802     * @param initWorkflowActionId The initial workflow action id
1803     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1804     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1805     * @param copiedContents the initial contents with their copied content
1806     * @param <C> The modifiable content return type
1807     * @return The created content
1808     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1809     * @throws AmetysRepositoryException If an error occurred
1810     * @throws WorkflowException If an error occurred
1811     */
1812    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
1813    {
1814        return _copyODFContent((Content) srcContent, srcContent.getCatalog(), srcContent.getCode(), targetContentName, targetContentLanguage, initWorkflowActionId, targetCatalog, fullCopy, copiedContents);
1815    }
1816    
1817    /**
1818     * Copy a {@link ProgramItem}. Also copy the synchronization metadata (status and alternative value)
1819     * @param srcContent The program item to copy
1820     * @param catalog The catalog
1821     * @param code The odf content code
1822     * @param targetContentName The name of content to created. Can be null. If null, the new name will be get from the source object.
1823     * @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.
1824     * @param initWorkflowActionId The initial workflow action id
1825     * @param fullCopy Set to <code>true</code> to copy the sub-structure
1826     * @param targetCatalog The target catalog. Can be null. The target catalog will be the catalog of the source object.
1827     * @param copiedContents the initial contents with their copied content
1828     * @param <C> The modifiable content return type
1829     * @return The created content
1830     * @throws AmetysObjectExistsException If a program item with same code, catalog and language already exists
1831     * @throws AmetysRepositoryException If an error occurred
1832     * @throws WorkflowException If an error occurred
1833     */
1834    @SuppressWarnings("unchecked")
1835    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
1836    {
1837        String computedTargetLanguage = targetContentLanguage;
1838        if (computedTargetLanguage == null)
1839        {
1840            computedTargetLanguage = srcContent.getLanguage();
1841        }
1842        
1843        String computeTargetName = targetContentName;
1844        if (computeTargetName == null)
1845        {
1846            // Compute content name from source content and requested language
1847            computeTargetName = srcContent.getName() + (targetContentLanguage != null && !targetContentLanguage.equals(srcContent.getName()) ? "-" + targetContentLanguage : "");
1848        }
1849        
1850        String computeTargetCatalog = targetCatalog;
1851        if (computeTargetCatalog == null)
1852        {
1853            computeTargetCatalog = catalog;
1854        }
1855        
1856        String principalContentType = srcContent.getTypes()[0];
1857        ModifiableContent createdContent = getODFContent(principalContentType, code, computeTargetCatalog, computedTargetLanguage);
1858        if (createdContent != null)
1859        {
1860            getLogger().info("A program item already exists with the same type, code, catalog and language [{}, {}, {}, {}]", principalContentType, code, computeTargetCatalog, targetContentLanguage);
1861        }
1862        else
1863        {
1864            // Copy content without notifying observers (done later) and copying ACL
1865            DataContext context = RepositoryDataContext.newInstance()
1866                                                       .withExternalMetadataInCopy(true);
1867            createdContent = ((DefaultContent) srcContent).copyTo(getRootContent(true), computeTargetName, targetContentLanguage, initWorkflowActionId, false, true, false, true, context);
1868            
1869            if (fullCopy)
1870            {
1871                _cleanContentMetadata(createdContent);
1872                
1873                if (targetCatalog != null)
1874                {
1875                    if (createdContent instanceof ProgramItem programItem)
1876                    {
1877                        programItem.setCatalog(targetCatalog);
1878                    }
1879                    else if (createdContent instanceof CoursePart coursePart)
1880                    {
1881                        coursePart.setCatalog(targetCatalog);
1882                    }
1883                    
1884                }
1885                
1886                if (srcContent instanceof ProgramItem programItem)
1887                {
1888                    copyProgramItemStructure(programItem, createdContent, computedTargetLanguage, initWorkflowActionId, computeTargetCatalog, copiedContents);
1889                }
1890                
1891                _extractOutgoingReferences(createdContent);
1892                
1893                createdContent.saveChanges();
1894            }
1895            
1896            // Notify observers after all structure has been copied
1897            _contentDAO.notifyContentCopied(createdContent, false);
1898            
1899            copiedContents.put(srcContent, createdContent);
1900        }
1901        
1902        return (C) createdContent;
1903    }
1904    
1905    /**
1906     * Copy the structure of a {@link ProgramItem}
1907     * @param srcContent the content to copy
1908     * @param targetContent the target content
1909     * @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.
1910     * @param initWorkflowActionId The initial workflow action id
1911     * @param targetCatalogName The target catalog. Can be null. The target catalog will be the catalog of the source object.
1912     * @param copiedContents the initial contents with their copied content
1913     * @throws AmetysRepositoryException If an error occurred during copy
1914     * @throws WorkflowException If an error occurred during copy
1915     */
1916    protected void copyProgramItemStructure(ProgramItem srcContent, ModifiableContent targetContent, String targetContentLanguage, int initWorkflowActionId, String targetCatalogName, Map<Content, Content> copiedContents) throws AmetysRepositoryException, WorkflowException
1917    {
1918        List<ProgramItem> srcChildContents = new ArrayList<>();
1919        Map<Pair<String, String>, List<String>> values = new HashMap<>();
1920        
1921        String childMetadataPath = null;
1922        String parentMetadataPath = null;
1923        
1924        if (srcContent instanceof TraversableProgramPart programPart)
1925        {
1926            childMetadataPath = TraversableProgramPart.CHILD_PROGRAM_PARTS;
1927            parentMetadataPath = ProgramPart.PARENT_PROGRAM_PARTS;
1928            srcChildContents.addAll(programPart.getProgramPartChildren());
1929        }
1930        else if (srcContent instanceof CourseList courseList)
1931        {
1932            childMetadataPath = CourseList.CHILD_COURSES;
1933            parentMetadataPath = Course.PARENT_COURSE_LISTS;
1934            srcChildContents.addAll(courseList.getCourses());
1935        }
1936        else if (srcContent instanceof Course course)
1937        {
1938            childMetadataPath = Course.CHILD_COURSE_LISTS;
1939            parentMetadataPath = CourseList.PARENT_COURSES;
1940            srcChildContents.addAll(course.getCourseLists());
1941
1942            List<String> refCoursePartIds = new ArrayList<>();
1943            for (CoursePart srcChildContent : course.getCourseParts())
1944            {
1945                CoursePart targetChildContent = copyCoursePart(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
1946                refCoursePartIds.add(targetChildContent.getId());
1947            }
1948            _addFormValues(values, Course.CHILD_COURSE_PARTS, CoursePart.PARENT_COURSES, refCoursePartIds);
1949        }
1950
1951        List<String> refChildIds = new ArrayList<>();
1952        for (ProgramItem srcChildContent : srcChildContents)
1953        {
1954            ProgramItem targetChildContent = copyProgramItem(srcChildContent, null, targetContentLanguage, initWorkflowActionId, targetCatalogName, true, copiedContents);
1955            refChildIds.add(targetChildContent.getId());
1956        }
1957
1958        _addFormValues(values, childMetadataPath, parentMetadataPath, refChildIds);
1959
1960        _editChildRelation((ModifiableWorkflowAwareContent) targetContent, values);
1961    }
1962    
1963    private void _addFormValues(Map<Pair<String, String>, List<String>> values, String childMetadataPath, String parentMetadataPath, List<String> refChildIds)
1964    {
1965        if (!refChildIds.isEmpty())
1966        {
1967            values.put(Pair.of(childMetadataPath, parentMetadataPath), refChildIds);
1968        }
1969    }
1970    
1971    private void _editChildRelation(ModifiableWorkflowAwareContent parentContent, Map<Pair<String, String>, List<String>> values) throws AmetysRepositoryException
1972    {
1973        if (!values.isEmpty())
1974        {
1975            for (Map.Entry<Pair<String, String>, List<String>> entry : values.entrySet())
1976            {
1977                String childMetadataName = entry.getKey().getLeft();
1978                String parentMetadataName = entry.getKey().getRight();
1979                List<String> childContents = entry.getValue();
1980                
1981                parentContent.setValue(childMetadataName, childContents.toArray(new String[childContents.size()]));
1982                
1983                for (String childContentId : childContents)
1984                {
1985                    ModifiableContent content = _resolver.resolveById(childContentId);
1986                    String[] parentContentIds = ContentDataHelper.getContentIdsArrayFromMultipleContentData(content, parentMetadataName);
1987                    content.setValue(parentMetadataName, ArrayUtils.add(parentContentIds, parentContent.getId()));
1988                    content.saveChanges();
1989                }
1990            }
1991        }
1992    }
1993    
1994    /**
1995     * Clean the CONTENT metadata created after a copy but whose values reference the initial content' structure
1996     * @param createdContent The created content to clean
1997     */
1998    protected void _cleanContentMetadata(ModifiableContent createdContent)
1999    {
2000        if (createdContent instanceof ProgramPart)
2001        {
2002            _removeFullValue(createdContent, ProgramPart.PARENT_PROGRAM_PARTS);
2003        }
2004        
2005        if (createdContent instanceof TraversableProgramPart)
2006        {
2007            _removeFullValue(createdContent, TraversableProgramPart.CHILD_PROGRAM_PARTS);
2008        }
2009        
2010        if (createdContent instanceof CourseList)
2011        {
2012            _removeFullValue(createdContent, CourseList.CHILD_COURSES);
2013            _removeFullValue(createdContent, CourseList.PARENT_COURSES);
2014        }
2015        
2016        if (createdContent instanceof Course)
2017        {
2018            _removeFullValue(createdContent, Course.CHILD_COURSE_LISTS);
2019            _removeFullValue(createdContent, Course.PARENT_COURSE_LISTS);
2020            _removeFullValue(createdContent, Course.CHILD_COURSE_PARTS);
2021        }
2022        
2023        if (createdContent instanceof CoursePart)
2024        {
2025            _removeFullValue(createdContent, CoursePart.PARENT_COURSES);
2026        }
2027    }
2028    
2029    private void _removeFullValue(ModifiableContent content, String attributeName)
2030    {
2031        content.removeValue(attributeName);
2032        content.removeExternalizableMetadataIfExists(attributeName);
2033    }
2034    
2035    private void _extractOutgoingReferences(ModifiableContent content)
2036    {
2037        Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content);
2038        content.setOutgoingReferences(outgoingReferencesByPath);
2039    }
2040    
2041    /**
2042     * Switch the ametys object to Live version if it has one
2043     * @param ao the Ametys object
2044     * @throws NoLiveVersionException if the content has no live version
2045     */
2046    public void switchToLiveVersion(DefaultAmetysObject ao) throws NoLiveVersionException
2047    {
2048        // Switch to the Live label if exists
2049        String[] allLabels = ao.getAllLabels();
2050        String[] currentLabels = ao.getLabels();
2051        
2052        boolean hasLiveVersion = Arrays.asList(allLabels).contains(CmsConstants.LIVE_LABEL);
2053        boolean currentVersionIsLive = Arrays.asList(currentLabels).contains(CmsConstants.LIVE_LABEL);
2054        
2055        if (hasLiveVersion && !currentVersionIsLive)
2056        {
2057            ao.switchToLabel(CmsConstants.LIVE_LABEL);
2058        }
2059        else if (!hasLiveVersion)
2060        {
2061            throw new NoLiveVersionException("The ametys object '" + ao.getId() + "' has no live version");
2062        }
2063    }
2064    
2065    /**
2066     * Switch to Live version if is required
2067     * @param ao the Ametys object
2068     * @throws NoLiveVersionException if the Live version is required but not exist
2069     */
2070    public void switchToLiveVersionIfNeeded(DefaultAmetysObject ao) throws NoLiveVersionException
2071    {
2072        Request request = _getRequest();
2073        if (request != null && request.getAttribute(REQUEST_ATTRIBUTE_VALID_LABEL) != null)
2074        {
2075            switchToLiveVersion(ao);
2076        }
2077    }
2078    
2079    /**
2080     * Count the hours accumulation in the {@link ProgramItem}
2081     * @param programItem The program item on which we compute the total number of hours
2082     * @return The hours accumulation
2083     */
2084    public Double getCumulatedHours(ProgramItem programItem)
2085    {
2086        // Ignore optional course list and avoid useless expensive calls
2087        if (programItem instanceof CourseList courseList && ChoiceType.OPTIONAL.equals(courseList.getType()))
2088        {
2089            return 0.0;
2090        }
2091
2092        List<ProgramItem> children = getChildProgramItems(programItem);
2093
2094        Double coef = 1.0;
2095        Double countNbHours = 0.0;
2096
2097        // If the program item is a course list, compute the coef (mandatory: 1, optional: 0, optional: min / total)
2098        if (programItem instanceof CourseList courseList)
2099        {
2100            // If there is no children, compute the coef is useless
2101            // Also choice list can throw an exception while dividing by zero
2102            if (children.isEmpty())
2103            {
2104                return 0.0;
2105            }
2106            
2107            switch (courseList.getType())
2108            {
2109                case CHOICE:
2110                    // Apply the average of number of EC from children multiply by the minimum ELP to select
2111                    coef = ((double) courseList.getMinNumberOfCourses()) / children.size();
2112                    break;
2113                case MANDATORY:
2114                default:
2115                    // Add all ECTS from children
2116                    break;
2117            }
2118        }
2119
2120        // If it's a course and we have a value for the number of hours
2121        // Then get the value
2122        if (programItem instanceof Course course && course.hasValue(Course.NUMBER_OF_HOURS))
2123        {
2124            countNbHours += course.<Double>getValue(Course.NUMBER_OF_HOURS);
2125        }
2126        // Else if there are program item children on the item
2127        // Then compute on children
2128        else if (children.size() > 0)
2129        {
2130            for (ProgramItem child : children)
2131            {
2132                countNbHours += getCumulatedHours(child);
2133            }
2134        }
2135        // Else, it's a course but there is no value for the number of hours and we don't have program item children
2136        // Then compute on course parts
2137        else if (programItem instanceof Course course)
2138        {
2139            countNbHours += course.getCourseParts()
2140                .stream()
2141                .mapToDouble(CoursePart::getNumberOfHours)
2142                .sum();
2143        }
2144        
2145        return coef * countNbHours;
2146    }
2147    
2148    /**
2149     * Get the request
2150     * @return the request
2151     */
2152    protected Request _getRequest()
2153    {
2154        return ContextHelper.getRequest(_context);
2155    }
2156    
2157    /**
2158     * Get the first orgunit matching the given UAI code
2159     * @param uaiCode the UAI code
2160     * @return the orgunit or null if not found
2161     */
2162    public OrgUnit getOrgUnitByUAICode(String uaiCode)
2163    {
2164        Expression expr = new AndExpression(
2165                new ContentTypeExpression(Operator.EQ, OrgUnitFactory.ORGUNIT_CONTENT_TYPE),
2166                new StringExpression(OrgUnit.CODE_UAI, Operator.EQ, uaiCode)
2167        );
2168        
2169        String xPathQuery = QueryHelper.getXPathQuery(null, OrgUnitFactory.ORGUNIT_NODETYPE, expr);
2170        AmetysObjectIterable<OrgUnit> orgUnits = _resolver.query(xPathQuery);
2171        
2172        return orgUnits.stream()
2173            .findFirst()
2174            .orElse(null);
2175    }
2176}