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.Collections;
020import java.util.HashSet;
021import java.util.List;
022import java.util.Optional;
023import java.util.Set;
024
025import org.apache.avalon.framework.activity.Initializable;
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030import org.apache.commons.lang.StringUtils;
031
032import org.ametys.cms.repository.Content;
033import org.ametys.core.cache.AbstractCacheManager;
034import org.ametys.core.cache.Cache;
035import org.ametys.odf.ODFHelper;
036import org.ametys.odf.ProgramItem;
037import org.ametys.odf.course.Course;
038import org.ametys.odf.courselist.CourseList;
039import org.ametys.odf.program.AbstractProgram;
040import org.ametys.odf.program.Program;
041import org.ametys.odf.program.ProgramPart;
042import org.ametys.odf.program.SubProgram;
043import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
044import org.ametys.plugins.odfweb.OdfURIResolver;
045import org.ametys.plugins.repository.AmetysObjectFactoryExtensionPoint;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.UnknownAmetysObjectException;
048import org.ametys.runtime.config.Config;
049import org.ametys.runtime.i18n.I18nizableText;
050import org.ametys.runtime.plugin.component.AbstractLogEnabled;
051import org.ametys.web.repository.page.Page;
052
053/**
054 * Resolves an ODF page path from the associated ODF content.
055 */
056public class OdfPageResolver extends AbstractLogEnabled implements Component, Serviceable, Initializable
057{
058    /** The avalon role. */
059    public static final String ROLE = OdfPageResolver.class.getName();
060    
061    private static final String __PATH_IN_SITEMAP_CACHE = OdfPageResolver.class.getName() + "$pathInSitemap";
062    private static final String __RESOLVED_PATH_CACHE = OdfPageResolver.class.getName() + "$resolvedPath";
063    private static final String __ODF_ROOT_PROGRAM_CACHE = OdfURIResolver.class.getName() + "$rootProgram";
064    
065    /** The ametys object resolver. */
066    protected AmetysObjectResolver _ametysResolver;
067    /** The odf page handler */
068    protected OdfPageHandler _odfPageHandler;
069    /** ODF helper */
070    protected ODFHelper _odfHelper;
071    /** The cache manager */
072    protected AbstractCacheManager _cacheManager;
073    /** The course page factory */
074    protected CoursePageFactory _coursePageFactory;
075    /** The program page factory */
076    protected ProgramPageFactory _programPageFactory;
077    
078    @Override
079    public void service(ServiceManager serviceManager) throws ServiceException
080    {
081        _ametysResolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
082        _odfPageHandler = (OdfPageHandler) serviceManager.lookup(OdfPageHandler.ROLE);
083        _odfHelper = (ODFHelper) serviceManager.lookup(ODFHelper.ROLE);
084        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
085        AmetysObjectFactoryExtensionPoint ametysObjectFactoryEP = (AmetysObjectFactoryExtensionPoint) serviceManager.lookup(AmetysObjectFactoryExtensionPoint.ROLE);
086        _coursePageFactory = (CoursePageFactory) ametysObjectFactoryEP.getExtension(CoursePageFactory.class.getName());
087        _programPageFactory = (ProgramPageFactory) ametysObjectFactoryEP.getExtension(ProgramPageFactory.class.getName());
088    }
089    
090    public void initialize() throws Exception
091    {
092        _cacheManager.createRequestCache(__PATH_IN_SITEMAP_CACHE,
093                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_PATH_IN_SITEMAP_LABEL"),
094                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_PATH_IN_SITEMAP_DESCRIPTION"),
095                false);
096        
097        _cacheManager.createRequestCache(__RESOLVED_PATH_CACHE,
098                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_RESOLVED_PATH_LABEL"),
099                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_RESOLVED_PATH_DESCRIPTION"),
100                false);
101        
102        _cacheManager.createRequestCache(__ODF_ROOT_PROGRAM_CACHE,
103                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_ROOT_PROGRAM_LABEL"),
104                new I18nizableText("plugin.odf-web", "PLUGINS_ODF_WEB_CACHE_ROOT_PROGRAM_DESCRIPTION"),
105                false);
106    }
107    
108    /**
109     * Get all referencing pages for this program item, in all sites and all sitemaps
110     * @param programItem The program item
111     * @return the referencing pages
112     */
113    public Set<Page> getReferencingPages(ProgramItem programItem)
114    {
115        return getReferencingPages(programItem, null, ((Content) programItem).getLanguage());
116    }
117    
118    /**
119     * Get all referencing pages for this program item
120     * @param programItem The program item
121     * @param siteName The site name. Can be null to search on all sites
122     * @param lang The sitemap language. Can be null to search on all sitemaps
123     * @return the referencing pages
124     */
125    public Set<Page> getReferencingPages(ProgramItem programItem, String siteName, String lang)
126    {
127        Set<Page> refPages = new HashSet<>();
128        
129        Set<Page> odfRootPages = _odfPageHandler.getOdfRootPages(siteName, lang);
130        
131        for (Page rootPage : odfRootPages)
132        {
133            if (programItem instanceof Program)
134            {
135                ProgramPage programPage = getProgramPage(rootPage, (Program) programItem);
136                if (programPage != null)
137                {
138                    refPages.add(programPage);
139                }
140            }
141            else if (programItem instanceof SubProgram)
142            {
143                Set<Program> parentPrograms = _odfHelper.getParentPrograms(programItem);
144                for (Program parentProgram : parentPrograms)
145                {
146                    ProgramPage subProgramPage = getSubProgramPage(rootPage, (SubProgram) programItem, parentProgram);
147                    if (subProgramPage != null)
148                    {
149                        refPages.add(subProgramPage);
150                    }
151                }
152            }
153            else if (programItem instanceof Course)
154            {
155                List<CourseList> parentCourseLists = ((Course) programItem).getParentCourseLists();
156                for (CourseList courseList : parentCourseLists)
157                {
158                    List<Course> parentCourses = courseList.getParentCourses();
159                    for (Course parentCourse : parentCourses)
160                    {
161                        CoursePage coursePage = getCoursePage(rootPage, (Course) programItem, parentCourse);
162                        if (coursePage != null)
163                        {
164                            refPages.add(coursePage);
165                        }
166                    }
167                    
168                    List<AbstractProgram> parentAbstractPrograms = getNearestAncestorAbstractPrograms(courseList);
169                    for (AbstractProgram parentAbstractProgram : parentAbstractPrograms)
170                    {
171                        CoursePage coursePage = getCoursePage(rootPage, (Course) programItem, parentAbstractProgram);
172                        if (coursePage != null)
173                        {
174                            refPages.add(coursePage);
175                        }
176                    }
177                }
178            }
179        }
180        
181        return refPages;
182    }
183    
184    /**
185     * Return the program page
186     * @param program the program
187     * @return the page program or null
188     */
189    public ProgramPage getProgramPage(Program program)
190    {
191        return getProgramPage(program, null);
192    }
193    
194    /**
195     * Return the program page
196     * @param program the program
197     * @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.
198     * @return the page program or null
199     */
200    public ProgramPage getProgramPage(Program program, String siteName)
201    {
202        Page odfRootPage = getOdfRootPage(siteName, program.getLanguage(), program.getCatalog());
203        
204        if (odfRootPage == null)
205        {
206            return null;
207        }
208        
209        return getProgramPage(odfRootPage, program);
210    }
211    
212    /**
213     * Return the program page
214     * @param odfRootPage the odf root page
215     * @param program the program
216     * @return the page program or null
217     */
218    public ProgramPage getProgramPage (Page odfRootPage, Program program)
219    {
220        // E.g: program://_root?rootId=xxxx&programId=xxxx
221        String pageId =  "program://_root?rootId=" + odfRootPage.getId() + "&programId=" + program.getId();
222        try
223        {
224            return _ametysResolver.resolveById(pageId);
225        }
226        catch (UnknownAmetysObjectException e)
227        {
228            return null;
229        }
230    }
231    
232    /**
233     * Return the subprogram page
234     * @param subProgram the subprogram
235     * @param parentProgram The parent program
236     * @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.
237     * @return the subprogram page or null
238     */
239    public ProgramPage getSubProgramPage(SubProgram subProgram, AbstractProgram parentProgram, String siteName)
240    {
241        Page odfRootPage = getOdfRootPage(siteName, subProgram.getLanguage(), subProgram.getCatalog());
242        
243        if (odfRootPage == null)
244        {
245            return null;
246        }
247        
248        return getSubProgramPage(odfRootPage, subProgram, parentProgram);
249    }
250    
251    /**
252     * Return the subprogram page
253     * @param odfRootPage the odf root page
254     * @param subProgram the subprogram
255     * @param parentAbstractProgram The parent program or subprogram
256     * @return the subprogram page or null
257     */
258    public ProgramPage getSubProgramPage (Page odfRootPage, SubProgram subProgram, AbstractProgram parentAbstractProgram)
259    {
260        try
261        {
262            // Get first parent program matching an existing program page
263            Program parentProgram = getRootProgram(odfRootPage, subProgram, parentAbstractProgram);
264            if (parentProgram == null)
265            {
266                // No program page
267                return null;
268            }
269            
270            AbstractProgram parent = getNearestAncestorAbstractProgram(subProgram, parentAbstractProgram);
271            if (parent == null)
272            {
273                return null; // no page
274            }
275         
276            String path = getPathInProgram(parent, parentProgram);
277            if (path == null)
278            {
279                // Subprogram is not part of the selected parent program
280                return null;
281            }
282            return _programPageFactory.createProgramPage(odfRootPage, subProgram, path, parentProgram, null);
283        }
284        catch (UnknownAmetysObjectException e)
285        {
286            return null;
287        }
288    }
289    
290    /**
291     * Returns the subprogram page
292     * @param odfRootPage the odf root page
293     * @param subProgram the subprogram
294     * @param path a full or partial path of subprogram
295     * @param checkHierarchy set to true to check that the given path is a valid hierarchy
296     * @return the subprogram page or null if not found
297     */
298    public ProgramPage getSubProgramPage(Page odfRootPage, SubProgram subProgram, List<String> path, boolean checkHierarchy)
299    {
300        // Possible paths are :
301        // [subprogramContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
302        // [programContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
303        
304        try
305        {
306            
307            ProgramItem lastParent = _ametysResolver.resolveById(path.get(0));
308            Program parentProgram = getRootProgram(odfRootPage, lastParent, null);
309            if (parentProgram == null)
310            {
311                // No page
312                return null;
313            }
314            
315            List<String> reversedPath = path.reversed();
316            if (checkHierarchy)
317            {
318                ProgramItem nextProgramItem = _ametysResolver.resolveById(path.getLast());
319                if (!_checkHierarchy(subProgram, nextProgramItem))
320                {
321                    // Parent item in path is not part of program item's parents
322                    getLogger().warn(reversedPath + " is not valid hierarchy");
323                    throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
324                }
325            }
326            
327            String pagePath = _resolvePagePath(reversedPath, checkHierarchy);
328            if (pagePath == null)
329            {
330                // No page
331                return null;
332            }
333            
334            return _programPageFactory.createProgramPage(odfRootPage, subProgram, pagePath, parentProgram, null);
335        }
336        catch (UnknownAmetysObjectException e)
337        {
338            return null;
339        }
340    }
341    
342    /**
343     * Return the course page
344     * @param course the course
345     * @param parentProgram the parent program or subprogram. Can be null.
346     * @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.
347     * @return the course page or null if not found
348     */
349    public CoursePage getCoursePage(Course course, AbstractProgram parentProgram, String siteName)
350    {
351        String catalog = course.getCatalog();
352        Page odfRootPage = getOdfRootPage(siteName, course.getLanguage(), catalog);
353        
354        if (odfRootPage == null)
355        {
356            return null;
357        }
358        
359        return getCoursePage(odfRootPage, course, parentProgram);
360    }
361    
362    /**
363     * Return the course page
364     * @param odfRootPage the odf root page
365     * @param course the course
366     * @param parentAbstractProgram the parent program or subprogram. Can be null.
367     * @return the course page or null if not found
368     */
369    public CoursePage getCoursePage (Page odfRootPage, Course course, AbstractProgram parentAbstractProgram)
370    {
371        try
372        {
373            // Get first parent program matching an existing program page
374            Program parentProgram = getRootProgram(odfRootPage, course, parentAbstractProgram);
375            if (parentProgram == null)
376            {
377                // No program page
378                return null;
379            }
380            
381            ProgramItem parent = null;
382            
383            Course parentCourse = getNearestAncestorCourse(course, parentAbstractProgram);
384            if (parentCourse != null)
385            {
386                parent = parentCourse;
387            }
388            else
389            {
390                parent = getNearestAncestorAbstractProgram(course, parentAbstractProgram);
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            if (programItem instanceof AbstractProgram || programItem instanceof Course)
675            {
676                paths.add(_odfPageHandler.getPageName(programItem));
677            }
678            
679            ProgramItem parent = _odfHelper.getParentProgramItem(programItem, parentProgram);
680            while (parent != null && !(parent instanceof Program))
681            {
682                if (parent instanceof AbstractProgram || parent instanceof Course)
683                {
684                    paths.add(_odfPageHandler.getPageName(parent));
685                }
686                parent = _odfHelper.getParentProgramItem(parent, parentProgram);
687            }
688            
689            if (parent != null)
690            {
691                paths.add(_odfPageHandler.getPageName(parent));
692                Collections.reverse(paths);
693                return org.apache.commons.lang3.StringUtils.join(paths, "/");
694            }
695            
696            return null;
697        });
698    }
699    
700    private static class RootProgramCacheKey extends AbstractCacheKey
701    {
702        public RootProgramCacheKey(Page odfRootPage, String programItemId, String parentProgramId)
703        {
704            super(odfRootPage, programItemId, parentProgramId);
705        }
706        
707        public static RootProgramCacheKey of(Page odfRootPage, String programItemId, String parentProgramId)
708        {
709            return new RootProgramCacheKey(odfRootPage, programItemId, parentProgramId);
710        }
711    }
712    
713    private static class PathInProgramCacheKey extends AbstractCacheKey
714    {
715        public PathInProgramCacheKey(String programItemId, String parentProgramId)
716        {
717            super(programItemId, parentProgramId);
718        }
719        
720        public static PathInProgramCacheKey of(String programItemId, String parentProgramId)
721        {
722            return new PathInProgramCacheKey(programItemId, parentProgramId);
723        }
724    }
725    
726    /**
727     * 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>
728     * If 'parentProgram' is null, the first {@link Program} ancestor will be returned regardless of parent hierarchy.<br>
729     * If 'parentProgram' is a {@link SubProgram}, the first {@link Program} ancestor from this {@link SubProgram} will be returned regardless of parent hierarchy
730     * @param odfRootPage The ODf root page. Cannot be null.
731     * @param programItem a {@link ProgramItem}
732     * @param parentAbstractProgram The parent program or subprogram. Can be null.
733     * @return the parent {@link Program} into this (sub)program that matchs an existing program page, or null if not found 
734     */
735    public Program getRootProgram(Page odfRootPage, ProgramItem programItem, AbstractProgram parentAbstractProgram)
736    {
737        Cache<RootProgramCacheKey, Program> rootCache = _cacheManager.get(__ODF_ROOT_PROGRAM_CACHE);
738        
739        return rootCache.get(RootProgramCacheKey.of(odfRootPage, programItem.getId(), parentAbstractProgram != null ? parentAbstractProgram.getId() : "__NOPARENT"), k -> {
740            // Get all parent programs
741            Set<Program> parentPrograms = _getParentPrograms(programItem, parentAbstractProgram);
742            
743            // Get first parent program matching an existing program page
744            Optional<Program> parentProgram = parentPrograms.stream()
745                    .filter(p -> isProgramPageExist(odfRootPage, p))
746                    .findFirst();
747            
748            return parentProgram.orElse(null);
749        });
750    }
751    
752    /**
753     * Returns all parent {@link Program} ancestors, ensuring that the given parent content 'parentProgram' is in the hierarchy, if not null.<br>
754     * If 'parentProgram' is null, the all {@link Program} ancestors will be returned regardless of parent hierarchy.<br>
755     * If 'parentProgram' is a {@link SubProgram}, the {@link Program} ancestors from this {@link SubProgram} will be returned regardless of parent hierarchy
756     * @param programItem a {@link ProgramItem}
757     * @param parentProgram The parent program or subprogram. Can be null.
758     * @return the parent {@link Program}s into this (sub)program or empty if not found
759     */
760    private Set<Program> _getParentPrograms(ProgramItem programItem, AbstractProgram parentProgram)
761    {
762        if (programItem instanceof Program program)
763        {
764            return Set.of(program);
765        }
766        
767        Set<Program> parentPrograms = new HashSet<>();
768        
769        AbstractProgram parent = parentProgram;
770        
771        List<ProgramItem> parentItems = _odfHelper.getParentProgramItems(programItem, parentProgram);
772        
773        for (ProgramItem parentItem : parentItems)
774        {
775            if (parentItem instanceof Program program)
776            {
777                parentPrograms.add(program);
778            }
779            else
780            {
781                if (parent != null && parentItem.equals(parent))
782                {
783                    // Once the desired abstract program parent is passed, the parent is null
784                    parent = null;
785                }
786                
787                parentPrograms.addAll(_getParentPrograms(parentItem, parent));
788            }
789        }
790        
791        return parentPrograms;
792    }
793    
794    /**
795     * Returns the nearest {@link AbstractProgram} ancestors.
796     * @param programPart a {@link ProgramPart}
797     * @return the nearest {@link AbstractProgram} ancestors containing this program part
798     */
799    public List<AbstractProgram> getNearestAncestorAbstractPrograms (ProgramPart programPart)
800    {
801        List<AbstractProgram> ancestors = new ArrayList<>();
802        
803        List<ProgramPart> parents = programPart.getProgramPartParents();
804        for (ProgramPart parent : parents)
805        {
806            if (parent instanceof AbstractProgram)
807            {
808                ancestors.add((AbstractProgram) parent);
809            }
810            else
811            {
812                ancestors.addAll(getNearestAncestorAbstractPrograms(parent));
813            }
814        }
815        
816        return ancestors;
817    }
818    
819    /**
820     * Returns the nearest {@link AbstractProgram} ancestor.
821     * @param programItem a {@link ProgramItem}
822     * @param parentProgram The parent program or subprogram
823     * @return the nearest {@link AbstractProgram} ancestor into this (sub)program or null if not found
824     */
825    public AbstractProgram getNearestAncestorAbstractProgram (ProgramItem programItem, AbstractProgram parentProgram)
826    {
827        ProgramItem parentItem = _odfHelper.getParentProgramItem(programItem, parentProgram);
828        while (parentItem != null && !(parentItem instanceof AbstractProgram))
829        {
830            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
831        }
832        
833        return parentItem != null ? (AbstractProgram) parentItem : null;
834    }
835    
836    /**
837     * Returns the nearest {@link Course} ancestor.
838     * @param course a {@link Course}
839     * @param parentProgram The parent program or subprogram
840     * @return the nearest {@link Course} ancestor into this (sub)program or null if not found
841     */
842    public Course getNearestAncestorCourse (Course course, AbstractProgram parentProgram)
843    {
844        ProgramItem parentItem = _odfHelper.getParentProgramItem(course, parentProgram);
845        while (parentItem != null && !(parentItem instanceof Course) && !(parentItem instanceof AbstractProgram))
846        {
847            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
848        }
849        
850        return parentItem != null && parentItem instanceof Course ? (Course) parentItem : null;
851    }
852    
853    /**
854     * Get the path of a Program page from the Program content.
855     * @param siteName the site name.
856     * @param language the language.
857     * @param program the program.
858     * @return the page path or empty if no page matches
859     */
860    public String getProgramPagePath(String siteName, String language, Program program)
861    {
862        return getProgramItemPagePath(siteName, language, program, program);
863    }
864    
865    /**
866     * Get the path of a {@link ProgramItem} page into the given {@link Program}
867     * @param siteName the site name
868     * @param language the language
869     * @param programItem the subprogram.
870     * @param parentProgram The parent program
871     * @return the page path or empty if no page matches
872     */
873    public String getProgramItemPagePath(String siteName, String language, ProgramItem programItem, Program parentProgram)
874    {
875        StringBuilder sb = new StringBuilder();
876        
877        Page rootPage = _odfPageHandler.getOdfRootPage(siteName, language, programItem.getCatalog());
878        if (rootPage != null)
879        {
880            if (programItem instanceof Program)
881            {
882                sb.append(rootPage.getSitemapName()).append('/')
883                    .append(rootPage.getPathInSitemap()).append('/')
884                    .append(_odfPageHandler.getLevel1PageName(rootPage, parentProgram)).append('/')
885                    .append(_odfPageHandler.getLevel2PageName(rootPage, parentProgram)).append('/')
886                    .append(_odfPageHandler.getPageName(programItem));
887            }
888            else
889            {
890                String pathInProgram = getPathInProgram(programItem, parentProgram);
891                if (pathInProgram != null)
892                {
893                    sb.append(rootPage.getSitemapName()).append('/')
894                        .append(rootPage.getPathInSitemap()).append('/')
895                        .append(_odfPageHandler.getLevel1PageName(rootPage, parentProgram)).append('/')
896                        .append(_odfPageHandler.getLevel2PageName(rootPage, parentProgram)).append('/')
897                        .append(pathInProgram);
898                }
899            }
900            
901        }
902        
903        return sb.toString();
904    }
905}