001/*
002 *  Copyright 2011 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.plugins.odfweb.repository;
017
018import java.util.ArrayList;
019import java.util.HashSet;
020import java.util.List;
021import java.util.Optional;
022import java.util.Set;
023
024import org.apache.avalon.framework.activity.Initializable;
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.cms.repository.Content;
032import org.ametys.core.cache.AbstractCacheManager;
033import org.ametys.core.cache.Cache;
034import org.ametys.odf.ODFHelper;
035import org.ametys.odf.ProgramItem;
036import org.ametys.odf.course.Course;
037import org.ametys.odf.courselist.CourseList;
038import org.ametys.odf.program.AbstractProgram;
039import org.ametys.odf.program.Program;
040import org.ametys.odf.program.ProgramPart;
041import org.ametys.odf.program.SubProgram;
042import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
043import org.ametys.plugins.repository.AmetysObjectFactoryExtensionPoint;
044import org.ametys.plugins.repository.AmetysObjectResolver;
045import org.ametys.plugins.repository.UnknownAmetysObjectException;
046import org.ametys.runtime.config.Config;
047import org.ametys.runtime.i18n.I18nizableText;
048import org.ametys.runtime.plugin.component.AbstractLogEnabled;
049import org.ametys.web.repository.page.Page;
050
051/**
052 * Resolves an ODF page path from the associated ODF content.
053 */
054public class OdfPageResolver extends AbstractLogEnabled implements Component, Serviceable, Initializable
055{
056    /** The avalon role. */
057    public static final String ROLE = OdfPageResolver.class.getName();
058    
059    private static final String __PATH_IN_SITEMAP_CACHE = OdfPageResolver.class.getName() + "$pathInSitemap";
060    private static final String __RESOLVED_PATH_CACHE = OdfPageResolver.class.getName() + "$resolvedPath";
061    private static final String __ODF_ROOT_PROGRAM_CACHE = OdfPageResolver.class.getName() + "$rootProgram";
062    
063    /** The ametys object resolver. */
064    protected AmetysObjectResolver _ametysResolver;
065    /** The odf page handler */
066    protected OdfPageHandler _odfPageHandler;
067    /** ODF helper */
068    protected ODFHelper _odfHelper;
069    /** The cache manager */
070    protected AbstractCacheManager _cacheManager;
071    /** The course page factory */
072    protected CoursePageFactory _coursePageFactory;
073    /** The program page factory */
074    protected ProgramPageFactory _programPageFactory;
075    
076    public void service(ServiceManager serviceManager) throws ServiceException
077    {
078        _ametysResolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
079        _odfPageHandler = (OdfPageHandler) serviceManager.lookup(OdfPageHandler.ROLE);
080        _odfHelper = (ODFHelper) serviceManager.lookup(ODFHelper.ROLE);
081        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
082        AmetysObjectFactoryExtensionPoint ametysObjectFactoryEP = (AmetysObjectFactoryExtensionPoint) serviceManager.lookup(AmetysObjectFactoryExtensionPoint.ROLE);
083        _coursePageFactory = (CoursePageFactory) ametysObjectFactoryEP.getExtension(CoursePageFactory.class.getName());
084        _programPageFactory = (ProgramPageFactory) ametysObjectFactoryEP.getExtension(ProgramPageFactory.class.getName());
085    }
086    
087    public void initialize() throws Exception
088    {
089        _cacheManager.createRequestCache(__PATH_IN_SITEMAP_CACHE,
090                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_PATH_IN_SITEMAP_LABEL"),
091                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_PATH_IN_SITEMAP_DESCRIPTION"),
092                false);
093        
094        _cacheManager.createRequestCache(__RESOLVED_PATH_CACHE,
095                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_RESOLVED_PATH_LABEL"),
096                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_RESOLVED_PATH_DESCRIPTION"),
097                false);
098        
099        _cacheManager.createRequestCache(__ODF_ROOT_PROGRAM_CACHE,
100                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_ROOT_PROGRAM_LABEL"),
101                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_ROOT_PROGRAM_DESCRIPTION"),
102                false);
103    }
104    
105    /**
106     * Get all referencing pages for this program item, in all sites and all sitemaps
107     * @param programItem The program item
108     * @return the referencing pages
109     */
110    public Set<Page> getReferencingPages(ProgramItem programItem)
111    {
112        return getReferencingPages(programItem, null, ((Content) programItem).getLanguage());
113    }
114    
115    /**
116     * Get all referencing pages for this program item
117     * @param programItem The program item
118     * @param siteName The site name. Can be null to search on all sites
119     * @param lang The sitemap language. Can be null to search on all sitemaps
120     * @return the referencing pages
121     */
122    public Set<Page> getReferencingPages(ProgramItem programItem, String siteName, String lang)
123    {
124        Set<Page> refPages = new HashSet<>();
125        
126        Set<Page> odfRootPages = _odfPageHandler.getOdfRootPages(siteName, lang);
127        
128        for (Page rootPage : odfRootPages)
129        {
130            if (programItem instanceof Program)
131            {
132                ProgramPage programPage = getProgramPage(rootPage, (Program) programItem);
133                if (programPage != null)
134                {
135                    refPages.add(programPage);
136                }
137            }
138            else if (programItem instanceof SubProgram)
139            {
140                Set<Program> parentPrograms = _odfHelper.getParentPrograms(programItem);
141                for (Program parentProgram : parentPrograms)
142                {
143                    ProgramPage subProgramPage = getSubProgramPage(rootPage, (SubProgram) programItem, parentProgram);
144                    if (subProgramPage != null)
145                    {
146                        refPages.add(subProgramPage);
147                    }
148                }
149            }
150            else if (programItem instanceof Course)
151            {
152                List<CourseList> parentCourseLists = ((Course) programItem).getParentCourseLists();
153                for (CourseList courseList : parentCourseLists)
154                {
155                    List<Course> parentCourses = courseList.getParentCourses();
156                    for (Course parentCourse : parentCourses)
157                    {
158                        CoursePage coursePage = getCoursePage(rootPage, (Course) programItem, parentCourse);
159                        if (coursePage != null)
160                        {
161                            refPages.add(coursePage);
162                        }
163                    }
164                    
165                    List<AbstractProgram> parentAbstractPrograms = getNearestAncestorAbstractPrograms(courseList);
166                    for (AbstractProgram parentAbstractProgram : parentAbstractPrograms)
167                    {
168                        CoursePage coursePage = getCoursePage(rootPage, (Course) programItem, parentAbstractProgram);
169                        if (coursePage != null)
170                        {
171                            refPages.add(coursePage);
172                        }
173                    }
174                }
175            }
176        }
177        
178        return refPages;
179    }
180    
181    /**
182     * Return the program page
183     * @param program the program
184     * @return the page program or null
185     */
186    public ProgramPage getProgramPage(Program program)
187    {
188        return getProgramPage(program, null);
189    }
190    
191    /**
192     * Return the program page
193     * @param program the program
194     * @param siteName The current site name. If the no ODF root page is present in this site, the default ODF site will be used instead.
195     * @return the page program or null
196     */
197    public ProgramPage getProgramPage(Program program, String siteName)
198    {
199        Page odfRootPage = getOdfRootPage(siteName, program.getLanguage(), program.getCatalog());
200        
201        if (odfRootPage == null)
202        {
203            return null;
204        }
205        
206        return getProgramPage(odfRootPage, program);
207    }
208    
209    /**
210     * Return the program page
211     * @param odfRootPage the odf root page
212     * @param program the program
213     * @return the page program or null
214     */
215    public ProgramPage getProgramPage (Page odfRootPage, Program program)
216    {
217        // E.g: program://_root?rootId=xxxx&programId=xxxx
218        String pageId =  "program://_root?rootId=" + odfRootPage.getId() + "&programId=" + program.getId();
219        try
220        {
221            return _ametysResolver.resolveById(pageId);
222        }
223        catch (UnknownAmetysObjectException e)
224        {
225            return null;
226        }
227    }
228    
229    /**
230     * Return the subprogram page
231     * @param subProgram the subprogram
232     * @param parentProgram The parent program
233     * @param siteName The current site name. If the no ODF root page is present in this site, the default ODF site will be used instead.
234     * @return the subprogram page or null
235     */
236    public ProgramPage getSubProgramPage(SubProgram subProgram, AbstractProgram parentProgram, String siteName)
237    {
238        Page odfRootPage = getOdfRootPage(siteName, subProgram.getLanguage(), subProgram.getCatalog());
239        
240        if (odfRootPage == null)
241        {
242            return null;
243        }
244        
245        return getSubProgramPage(odfRootPage, subProgram, parentProgram);
246    }
247    
248    /**
249     * Return the subprogram page
250     * @param odfRootPage the odf root page
251     * @param subProgram the subprogram
252     * @param parentAbstractProgram The parent program or subprogram
253     * @return the subprogram page or null
254     */
255    public ProgramPage getSubProgramPage (Page odfRootPage, SubProgram subProgram, AbstractProgram parentAbstractProgram)
256    {
257        try
258        {
259            // Get first parent program matching an existing program page
260            Program parentProgram = getRootProgram(odfRootPage, subProgram, parentAbstractProgram);
261            if (parentProgram == null)
262            {
263                // No program page
264                return null;
265            }
266            
267            AbstractProgram nearestParentAbstractProgram = Optional.ofNullable(parentAbstractProgram).orElse(parentProgram);
268            AbstractProgram parent = getNearestAncestorAbstractProgram(subProgram, nearestParentAbstractProgram);
269            if (parent == null)
270            {
271                return null; // no page
272            }
273         
274            String path = getPathInProgram(parent, parentProgram);
275            if (path == null)
276            {
277                // Subprogram is not part of the selected parent program
278                return null;
279            }
280            return _programPageFactory.createProgramPage(odfRootPage, subProgram, path, parentProgram, null);
281        }
282        catch (UnknownAmetysObjectException e)
283        {
284            return null;
285        }
286    }
287    
288    /**
289     * Returns the subprogram page
290     * @param odfRootPage the odf root page
291     * @param subProgram the subprogram
292     * @param path a full or partial path of subprogram
293     * @param checkHierarchy set to true to check that the given path is a valid hierarchy
294     * @return the subprogram page or null if not found
295     */
296    public ProgramPage getSubProgramPage(Page odfRootPage, SubProgram subProgram, List<String> path, boolean checkHierarchy)
297    {
298        // Possible paths are :
299        // [subprogramContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
300        // [programContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
301        
302        try
303        {
304            
305            ProgramItem lastParent = _ametysResolver.resolveById(path.get(0));
306            Program parentProgram = getRootProgram(odfRootPage, lastParent, null);
307            if (parentProgram == null)
308            {
309                // No page
310                return null;
311            }
312            
313            List<String> reversedPath = path.reversed();
314            if (checkHierarchy)
315            {
316                ProgramItem nextProgramItem = _ametysResolver.resolveById(path.getLast());
317                if (!_checkHierarchy(subProgram, nextProgramItem))
318                {
319                    // Parent item in path is not part of program item's parents
320                    getLogger().warn(reversedPath + " is not valid hierarchy");
321                    throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
322                }
323            }
324            
325            String pagePath = _resolvePagePath(reversedPath, checkHierarchy);
326            if (pagePath == null)
327            {
328                // No page
329                return null;
330            }
331            
332            return _programPageFactory.createProgramPage(odfRootPage, subProgram, pagePath, parentProgram, null);
333        }
334        catch (UnknownAmetysObjectException e)
335        {
336            return null;
337        }
338    }
339    
340    /**
341     * Return the course page
342     * @param course the course
343     * @param parentProgram the parent program or subprogram. Can be null.
344     * @param siteName The current site name. If the no ODF root page is present in this site, the default ODF site will be used instead.
345     * @return the course page or null if not found
346     */
347    public CoursePage getCoursePage(Course course, AbstractProgram parentProgram, String siteName)
348    {
349        String catalog = course.getCatalog();
350        Page odfRootPage = getOdfRootPage(siteName, course.getLanguage(), catalog);
351        
352        if (odfRootPage == null)
353        {
354            return null;
355        }
356        
357        return getCoursePage(odfRootPage, course, parentProgram);
358    }
359    
360    /**
361     * Return the course page
362     * @param odfRootPage the odf root page
363     * @param course the course
364     * @param parentAbstractProgram the parent program or subprogram. Can be null.
365     * @return the course page or null if not found
366     */
367    public CoursePage getCoursePage (Page odfRootPage, Course course, AbstractProgram parentAbstractProgram)
368    {
369        try
370        {
371            // Get first parent program matching an existing program page
372            Program parentProgram = getRootProgram(odfRootPage, course, parentAbstractProgram);
373            if (parentProgram == null)
374            {
375                // No program page
376                return null;
377            }
378            
379            AbstractProgram nearestParentAbstractProgram = Optional.ofNullable(parentAbstractProgram).orElse(parentProgram);
380            
381            ProgramItem parent = null;
382            
383            Course parentCourse = getNearestAncestorCourse(course, nearestParentAbstractProgram);
384            if (parentCourse != null)
385            {
386                parent = parentCourse;
387            }
388            else
389            {
390                parent = getNearestAncestorAbstractProgram(course, nearestParentAbstractProgram);
391            }
392            
393            if (parent == null)
394            {
395                return null; // no page
396            }
397         
398            String path = getPathInProgram(parent, parentProgram);
399            if (path == null)
400            {
401                // Course is not part of the selected parent program
402                return null;
403            }
404            
405            return _coursePageFactory.createCoursePage(odfRootPage, course, parentProgram, path, null);
406        }
407        catch (UnknownAmetysObjectException e)
408        {
409            return null;
410        }
411    }
412    
413    /**
414     * Return the course page
415     * @param course the course
416     * @param parentCourse the parent course. Can NOT be null.
417     * @param siteName The current site name. If the no ODF root page is present in this site, the default ODF site will be used instead.
418     * @return the course page or null if not found
419     */
420    public CoursePage getCoursePage (Course course, Course parentCourse, String siteName)
421    {
422        String catalog = course.getCatalog();
423        Page odfRootPage = getOdfRootPage(siteName, course.getLanguage(), catalog);
424        
425        if (odfRootPage == null)
426        {
427            return null;
428        }
429        
430        return getCoursePage(odfRootPage, course, parentCourse);
431    }
432    
433    /**
434     * Return the course page
435     * @param odfRootPage the odf root page
436     * @param course the course
437     * @param parentCourse the parent course. Can NOT be null.
438     * @return the course page or null if not found
439     */
440    public CoursePage getCoursePage (Page odfRootPage, Course course, Course parentCourse)
441    {
442        try
443        {
444            // Get first parent program matching an existing program page
445            Program parentProgram = getRootProgram(odfRootPage, parentCourse, null);
446            if (parentProgram == null)
447            {
448                // No page
449                return null;
450            }
451         
452            String path = getPathInProgram(parentCourse, parentProgram);
453            if (path == null)
454            {
455                // Course is not part of the selected parent program
456                return null;
457            }
458            return _coursePageFactory.createCoursePage(odfRootPage, course, parentProgram, path, null);
459        }
460        catch (UnknownAmetysObjectException e)
461        {
462            return null;
463        }
464    }
465    
466    /**
467     * Returns the course page in given ODF root page,
468     * @param odfRootPage the odf root page
469     * @param course the course
470     * @param path a (partial) education path or a (partial) sitemap path. Be careful, assume that the given path correspond to a valid path in ODF root. Use {@link #getCoursePage(Page, Course, List, boolean)} with true if not sure.
471     * @return the course page or null if not found
472     */
473    public CoursePage getCoursePage(Page odfRootPage, Course course, List<String> path)
474    {
475        return getCoursePage(odfRootPage, course, path, false);
476    }
477    
478    /**
479     * Returns the course page in given ODF root page,
480     * @param odfRootPage the odf root page
481     * @param course the course
482     * @param path a (partial) education path or a (partial) sitemap path
483     * @param checkHierarchy set to true to check that the given path correspond to a valid hierarchy
484     * @return the course page or null if not found
485     */
486    public CoursePage getCoursePage(Page odfRootPage, Course course, List<String> path, boolean checkHierarchy)
487    {
488        // Possible paths are :
489        // [(sub)programContent://UUID, container://UUID, courseContent://UUID1, courseContent://UUID2]
490        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3]
491        // [subprogramContent://UUID2, ..., (sub)programContent://UUID3, courseContent://UUID1]
492        
493        try
494        {
495            // Get first parent program matching an existing program page
496            ProgramItem lastParent = _ametysResolver.resolveById(path.get(0));
497            Program rootProgram = getRootProgram(odfRootPage, lastParent, null);
498            if (rootProgram == null)
499            {
500                // No page
501                return null;
502            }
503            
504            List<String> reversedPath = path.reversed();
505            if (checkHierarchy)
506            {
507                ProgramItem nextProgramItem = _ametysResolver.resolveById(path.getLast());
508                if (!_checkHierarchy(course, nextProgramItem))
509                {
510                    // Parent item in path is not part of program item's parents
511                    getLogger().warn(reversedPath + " is not valid hierarchy");
512                    throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
513                }
514            }
515            
516            String pagePath = _resolvePagePath(reversedPath, checkHierarchy);
517            if (pagePath == null)
518            {
519                // No page
520                return null;
521            }
522            
523            return _coursePageFactory.createCoursePage(odfRootPage, course, rootProgram, pagePath, null);
524        }
525        catch (UnknownAmetysObjectException e)
526        {
527            return null;
528        }
529    }
530    
531    /**
532     * Determines if a program page exists
533     * @param odfRootPage the ODF root page
534     * @param program the program page
535     * @return true if program page existes, false otherwise
536     */
537    public boolean isProgramPageExist(Page odfRootPage, Program program)
538    {
539        if (program == null || !_odfPageHandler.isValidRestriction(odfRootPage, program))
540        {
541            // No program page
542            return false;
543        }
544        
545        String levelsPath = _odfPageHandler.computeLevelsPath(odfRootPage, program);
546        if (levelsPath == null)
547        {
548            // The current program has no valid attributes for the levels selected in the ODF root
549            return false;
550        }
551        
552        return true;
553    }
554    
555    /**
556     * Get the ODF root page, either in the given site if it exists, or in the default ODF site.
557     * @param siteName the desired site name.
558     * @param language the sitemap language to search in.
559     * @param catalog The ODF catalog
560     * @return the ODF root page, either in the given site if it exists, or in the default ODF site.
561     */
562    public Page getOdfRootPage(String siteName, String language, String catalog)
563    {
564        Page odfRootPage = null;
565        
566        if (StringUtils.isNotEmpty(siteName))
567        {
568            odfRootPage = _odfPageHandler.getOdfRootPage(siteName, language, catalog);
569        }
570        
571        if (odfRootPage == null)
572        {
573            String odfSiteName = Config.getInstance().getValue("odf.web.site.name");
574            odfRootPage = _odfPageHandler.getOdfRootPage(odfSiteName, language, catalog);
575        }
576        
577        return odfRootPage;
578    }
579    
580    private String _resolvePagePath(List<String> reversedPath, boolean checkHierarchy)
581    {
582        // Possible reversed paths are :
583        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3, (sub)programContent://UUID]
584        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3]
585        // [courseContent://UUID1, subprogramContent://UUID2, ..., (sub)programContent://UUID3]
586        
587        Cache<String, String> cache = _cacheManager.get(__RESOLVED_PATH_CACHE);
588        return cache.get(StringUtils.join(reversedPath, ";"), item -> {
589            String pagePath = null;
590            
591            try
592            {
593                if (reversedPath.size() == 1)
594                {
595                    ProgramItem programItem = _ametysResolver.resolveById(reversedPath.get(0));
596                    pagePath = getPathInProgram(programItem, null);
597                }
598                else
599                {
600                    String parentPath = _resolvePagePath(reversedPath.subList(1, reversedPath.size()), checkHierarchy);
601                    if (parentPath != null)
602                    {
603                        ProgramItem programItem = _ametysResolver.resolveById(reversedPath.get(0));
604                        if (checkHierarchy)
605                        {
606                            // Get next parent given in path
607                            ProgramItem nextProgramItem = _ametysResolver.resolveById(reversedPath.get(1));
608                            // Check that next parent is a parent item
609                            if (!_checkHierarchy(programItem, nextProgramItem))
610                            {
611                                // Parent item in path is not part of program item's parents
612                                getLogger().warn(reversedPath + " is not valid hierarchy");
613                                throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
614                            }
615                        }
616                        if (programItem instanceof AbstractProgram || programItem instanceof Course)
617                        {
618                            parentPath += '/' + _odfPageHandler.getPageName(programItem);
619                        }
620                        
621                        pagePath = parentPath;
622                    }
623                }
624            }
625            catch (UnknownAmetysObjectException e)
626            {
627                // Nothing
628            }
629            
630            cache.put(StringUtils.join(reversedPath, ";"), pagePath);
631            return pagePath;
632        });
633    }
634    
635    private boolean _checkHierarchy(ProgramItem childProgramItem, ProgramItem nextParentProgramItem)
636    {
637        List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems(childProgramItem);
638        if (parentProgramItems.contains(nextParentProgramItem))
639        {
640            return true;
641        }
642        
643        for (ProgramItem parentProgramItem : parentProgramItems)
644        {
645            if (!(parentProgramItem instanceof AbstractProgram) && !(parentProgramItem instanceof Course) && _checkHierarchy(parentProgramItem, nextParentProgramItem))
646            {
647                return true;
648            }
649        }
650        
651        return false;
652    }
653    
654    /**
655     * Get the path in sitemap of a ODF content into a {@link Program} or {@link SubProgram}<br>
656     * Be careful, this is the path in sitemap, to get the path of a item into a Program, use {@link ODFHelper#getPathInProgram} instead.
657     * @param programItem The program item
658     * @param parentProgram The parent root (sub)program. Can be null.
659     * @return the path in sitemap from the parent program or null if no found ODF path for those program item and parent program
660     */
661    public String getPathInProgram (ProgramItem programItem, AbstractProgram parentProgram)
662    {
663        Cache<PathInProgramCacheKey, String> cache = _cacheManager.get(__PATH_IN_SITEMAP_CACHE);
664        
665        return cache.get(PathInProgramCacheKey.of(programItem.getId(), parentProgram != null ? parentProgram.getId() : "__NOPARENT"), item -> {
666            
667            if (programItem instanceof Program || programItem.equals(parentProgram))
668            {
669                // The program item is already the program it self
670                return _odfPageHandler.getPageName(programItem);
671            }
672            
673            List<String> paths = new ArrayList<>();
674
675            // Add the parent path in program if exists
676            ProgramItem parent = _odfHelper.getParentProgramItem(programItem, parentProgram);
677            if (parent != null)
678            {
679                String parentPath = getPathInProgram(parent, parentProgram);
680                if (parentPath != null)
681                {
682                    paths.add(parentPath);
683                }
684            }
685            
686            // Add the current page name (if it is an item with a page (only course, subprogram and program)
687            if (programItem instanceof AbstractProgram || programItem instanceof Course)
688            {
689                paths.add(_odfPageHandler.getPageName(programItem));
690            }
691
692            // If the path is empty, return null
693            return paths.isEmpty() ? null : StringUtils.join(paths, "/");
694        });
695    }
696    
697    private static class RootProgramCacheKey extends AbstractCacheKey
698    {
699        protected RootProgramCacheKey(Page odfRootPage, String programItemId, String parentProgramId)
700        {
701            super(odfRootPage, programItemId, parentProgramId);
702        }
703        
704        public static RootProgramCacheKey of(Page odfRootPage, String programItemId, String parentProgramId)
705        {
706            return new RootProgramCacheKey(odfRootPage, programItemId, parentProgramId);
707        }
708    }
709    
710    private static class PathInProgramCacheKey extends AbstractCacheKey
711    {
712        protected PathInProgramCacheKey(String programItemId, String parentProgramId)
713        {
714            super(programItemId, parentProgramId);
715        }
716        
717        public static PathInProgramCacheKey of(String programItemId, String parentProgramId)
718        {
719            return new PathInProgramCacheKey(programItemId, parentProgramId);
720        }
721    }
722    
723    /**
724     * Returns the first {@link Program} ancestor matching an existing {@link ProgramPage} in given ODF root page, ensuring that the given parent content 'parentProgram' is in the hierarchy (if not null)<br>
725     * If 'parentProgram' is null, the first {@link Program} ancestor will be returned regardless of parent hierarchy.<br>
726     * If 'parentProgram' is a {@link SubProgram}, the first {@link Program} ancestor from this {@link SubProgram} will be returned regardless of parent hierarchy
727     * @param odfRootPage The ODf root page. Cannot be null.
728     * @param programItem a {@link ProgramItem}
729     * @param parentAbstractProgram The parent program or subprogram. Can be null.
730     * @return the parent {@link Program} into this (sub)program that matchs an existing program page, or null if not found
731     */
732    public Program getRootProgram(Page odfRootPage, ProgramItem programItem, AbstractProgram parentAbstractProgram)
733    {
734        Cache<RootProgramCacheKey, Program> rootCache = _cacheManager.get(__ODF_ROOT_PROGRAM_CACHE);
735        
736        return rootCache.get(RootProgramCacheKey.of(odfRootPage, programItem.getId(), parentAbstractProgram != null ? parentAbstractProgram.getId() : "__NOPARENT"), k -> {
737            // Get all parent programs
738            Set<Program> parentPrograms = _getParentPrograms(programItem, parentAbstractProgram);
739            
740            // Get first parent program matching an existing program page
741            Optional<Program> parentProgram = parentPrograms.stream()
742                    .filter(p -> isProgramPageExist(odfRootPage, p))
743                    .findFirst();
744            
745            return parentProgram.orElse(null);
746        });
747    }
748    
749    /**
750     * Returns all parent {@link Program} ancestors, ensuring that the given parent content 'parentProgram' is in the hierarchy, if not null.<br>
751     * If 'parentProgram' is null, the all {@link Program} ancestors will be returned regardless of parent hierarchy.<br>
752     * If 'parentProgram' is a {@link SubProgram}, the {@link Program} ancestors from this {@link SubProgram} will be returned regardless of parent hierarchy
753     * @param programItem a {@link ProgramItem}
754     * @param parentProgram The parent program or subprogram. Can be null.
755     * @return the parent {@link Program}s into this (sub)program or empty if not found
756     */
757    private Set<Program> _getParentPrograms(ProgramItem programItem, AbstractProgram parentProgram)
758    {
759        if (programItem instanceof Program program)
760        {
761            return Set.of(program);
762        }
763        
764        Set<Program> parentPrograms = new HashSet<>();
765        
766        AbstractProgram parent = parentProgram;
767        
768        List<ProgramItem> parentItems = _odfHelper.getParentProgramItems(programItem, parentProgram);
769        
770        for (ProgramItem parentItem : parentItems)
771        {
772            if (parentItem instanceof Program program)
773            {
774                parentPrograms.add(program);
775            }
776            else
777            {
778                if (parent != null && parentItem.equals(parent))
779                {
780                    // Once the desired abstract program parent is passed, the parent is null
781                    parent = null;
782                }
783                
784                parentPrograms.addAll(_getParentPrograms(parentItem, parent));
785            }
786        }
787        
788        return parentPrograms;
789    }
790    
791    /**
792     * Returns the nearest {@link AbstractProgram} ancestors.
793     * @param programPart a {@link ProgramPart}
794     * @return the nearest {@link AbstractProgram} ancestors containing this program part
795     */
796    public List<AbstractProgram> getNearestAncestorAbstractPrograms (ProgramPart programPart)
797    {
798        List<AbstractProgram> ancestors = new ArrayList<>();
799        
800        List<ProgramPart> parents = programPart.getProgramPartParents();
801        for (ProgramPart parent : parents)
802        {
803            if (parent instanceof AbstractProgram)
804            {
805                ancestors.add((AbstractProgram) parent);
806            }
807            else
808            {
809                ancestors.addAll(getNearestAncestorAbstractPrograms(parent));
810            }
811        }
812        
813        return ancestors;
814    }
815    
816    /**
817     * Returns the nearest {@link AbstractProgram} ancestor.
818     * @param programItem a {@link ProgramItem}
819     * @param parentProgram The parent program or subprogram
820     * @return the nearest {@link AbstractProgram} ancestor into this (sub)program or null if not found
821     */
822    public AbstractProgram getNearestAncestorAbstractProgram (ProgramItem programItem, AbstractProgram parentProgram)
823    {
824        ProgramItem parentItem = _odfHelper.getParentProgramItem(programItem, parentProgram);
825        while (parentItem != null && !(parentItem instanceof AbstractProgram))
826        {
827            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
828        }
829        
830        return parentItem != null ? (AbstractProgram) parentItem : null;
831    }
832    
833    /**
834     * Returns the nearest {@link Course} ancestor.
835     * @param course a {@link Course}
836     * @param parentProgram The parent program or subprogram
837     * @return the nearest {@link Course} ancestor into this (sub)program or null if not found
838     */
839    public Course getNearestAncestorCourse (Course course, AbstractProgram parentProgram)
840    {
841        ProgramItem parentItem = _odfHelper.getParentProgramItem(course, parentProgram);
842        while (parentItem != null && !(parentItem instanceof Course) && !(parentItem instanceof AbstractProgram))
843        {
844            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
845        }
846        
847        return parentItem != null && parentItem instanceof Course ? (Course) parentItem : null;
848    }
849    
850    /**
851     * Get the path of a {@link ProgramItem} page into the given {@link Program}
852     * @param siteName the site name
853     * @param language the language
854     * @param programItem the subprogram.
855     * @param parentProgram The parent program
856     * @return the page path or empty if no page matches
857     */
858    public String getProgramItemPagePath(String siteName, String language, ProgramItem programItem, Program parentProgram)
859    {
860        Page rootPage = _odfPageHandler.getOdfRootPage(siteName, language, programItem.getCatalog());
861        if (rootPage != null)
862        {
863            Page page = null;
864            if (programItem instanceof Program program)
865            {
866                page = getProgramPage(rootPage, program);
867            }
868            else if (programItem instanceof SubProgram subProgram)
869            {
870                page = getSubProgramPage(rootPage, subProgram, parentProgram);
871            }
872            else if (programItem instanceof Course course)
873            {
874                page = getCoursePage(rootPage, course, parentProgram);
875            }
876            
877            if (page != null)
878            {
879                return rootPage.getSitemapName() + "/" + page.getPathInSitemap();
880            }
881        }
882        
883        return StringUtils.EMPTY;
884    }
885}