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