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