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 parent = getNearestAncestorAbstractProgram(subProgram, parentAbstractProgram);
268            if (parent == null)
269            {
270                return null; // no page
271            }
272         
273            String path = getPathInProgram(parent, parentProgram);
274            if (path == null)
275            {
276                // Subprogram is not part of the selected parent program
277                return null;
278            }
279            return _programPageFactory.createProgramPage(odfRootPage, subProgram, path, parentProgram, null);
280        }
281        catch (UnknownAmetysObjectException e)
282        {
283            return null;
284        }
285    }
286    
287    /**
288     * Returns the subprogram page
289     * @param odfRootPage the odf root page
290     * @param subProgram the subprogram
291     * @param path a full or partial path of subprogram
292     * @param checkHierarchy set to true to check that the given path is a valid hierarchy
293     * @return the subprogram page or null if not found
294     */
295    public ProgramPage getSubProgramPage(Page odfRootPage, SubProgram subProgram, List<String> path, boolean checkHierarchy)
296    {
297        // Possible paths are :
298        // [subprogramContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
299        // [programContent://UUID, subprogramContent://UUID, subprogramContent://UUID]
300        
301        try
302        {
303            
304            ProgramItem lastParent = _ametysResolver.resolveById(path.get(0));
305            Program parentProgram = getRootProgram(odfRootPage, lastParent, null);
306            if (parentProgram == null)
307            {
308                // No page
309                return null;
310            }
311            
312            List<String> reversedPath = path.reversed();
313            if (checkHierarchy)
314            {
315                ProgramItem nextProgramItem = _ametysResolver.resolveById(path.getLast());
316                if (!_checkHierarchy(subProgram, nextProgramItem))
317                {
318                    // Parent item in path is not part of program item's parents
319                    getLogger().warn(reversedPath + " is not valid hierarchy");
320                    throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
321                }
322            }
323            
324            String pagePath = _resolvePagePath(reversedPath, checkHierarchy);
325            if (pagePath == null)
326            {
327                // No page
328                return null;
329            }
330            
331            return _programPageFactory.createProgramPage(odfRootPage, subProgram, pagePath, parentProgram, null);
332        }
333        catch (UnknownAmetysObjectException e)
334        {
335            return null;
336        }
337    }
338    
339    /**
340     * Return the course page
341     * @param course the course
342     * @param parentProgram the parent program or subprogram. Can be null.
343     * @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.
344     * @return the course page or null if not found
345     */
346    public CoursePage getCoursePage(Course course, AbstractProgram parentProgram, String siteName)
347    {
348        String catalog = course.getCatalog();
349        Page odfRootPage = getOdfRootPage(siteName, course.getLanguage(), catalog);
350        
351        if (odfRootPage == null)
352        {
353            return null;
354        }
355        
356        return getCoursePage(odfRootPage, course, parentProgram);
357    }
358    
359    /**
360     * Return the course page
361     * @param odfRootPage the odf root page
362     * @param course the course
363     * @param parentAbstractProgram the parent program or subprogram. Can be null.
364     * @return the course page or null if not found
365     */
366    public CoursePage getCoursePage (Page odfRootPage, Course course, AbstractProgram parentAbstractProgram)
367    {
368        try
369        {
370            // Get first parent program matching an existing program page
371            Program parentProgram = getRootProgram(odfRootPage, course, parentAbstractProgram);
372            if (parentProgram == null)
373            {
374                // No program page
375                return null;
376            }
377            
378            ProgramItem parent = null;
379            
380            Course parentCourse = getNearestAncestorCourse(course, parentAbstractProgram);
381            if (parentCourse != null)
382            {
383                parent = parentCourse;
384            }
385            else
386            {
387                parent = getNearestAncestorAbstractProgram(course, parentAbstractProgram);
388            }
389            
390            if (parent == null)
391            {
392                return null; // no page
393            }
394         
395            String path = getPathInProgram(parent, parentProgram);
396            if (path == null)
397            {
398                // Course is not part of the selected parent program
399                return null;
400            }
401            
402            return _coursePageFactory.createCoursePage(odfRootPage, course, parentProgram, path, null);
403        }
404        catch (UnknownAmetysObjectException e)
405        {
406            return null;
407        }
408    }
409    
410    /**
411     * Return the course page
412     * @param course the course
413     * @param parentCourse the parent course. Can NOT be null.
414     * @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.
415     * @return the course page or null if not found
416     */
417    public CoursePage getCoursePage (Course course, Course parentCourse, String siteName)
418    {
419        String catalog = course.getCatalog();
420        Page odfRootPage = getOdfRootPage(siteName, course.getLanguage(), catalog);
421        
422        if (odfRootPage == null)
423        {
424            return null;
425        }
426        
427        return getCoursePage(odfRootPage, course, parentCourse);
428    }
429    
430    /**
431     * Return the course page
432     * @param odfRootPage the odf root page
433     * @param course the course
434     * @param parentCourse the parent course. Can NOT be null.
435     * @return the course page or null if not found
436     */
437    public CoursePage getCoursePage (Page odfRootPage, Course course, Course parentCourse)
438    {
439        try
440        {
441            // Get first parent program matching an existing program page
442            Program parentProgram = getRootProgram(odfRootPage, parentCourse, null);
443            if (parentProgram == null)
444            {
445                // No page
446                return null;
447            }
448         
449            String path = getPathInProgram(parentCourse, parentProgram);
450            if (path == null)
451            {
452                // Course is not part of the selected parent program
453                return null;
454            }
455            return _coursePageFactory.createCoursePage(odfRootPage, course, parentProgram, path, null);
456        }
457        catch (UnknownAmetysObjectException e)
458        {
459            return null;
460        }
461    }
462    
463    /**
464     * Returns the course page in given ODF root page,
465     * @param odfRootPage the odf root page
466     * @param course the course
467     * @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.
468     * @return the course page or null if not found
469     */
470    public CoursePage getCoursePage(Page odfRootPage, Course course, List<String> path)
471    {
472        return getCoursePage(odfRootPage, course, path, false);
473    }
474    
475    /**
476     * Returns the course page in given ODF root page,
477     * @param odfRootPage the odf root page
478     * @param course the course
479     * @param path a (partial) education path or a (partial) sitemap path
480     * @param checkHierarchy set to true to check that the given path correspond to a valid hierarchy
481     * @return the course page or null if not found
482     */
483    public CoursePage getCoursePage(Page odfRootPage, Course course, List<String> path, boolean checkHierarchy)
484    {
485        // Possible paths are :
486        // [(sub)programContent://UUID, container://UUID, courseContent://UUID1, courseContent://UUID2]
487        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3]
488        // [subprogramContent://UUID2, ..., (sub)programContent://UUID3, courseContent://UUID1]
489        
490        try
491        {
492            // Get first parent program matching an existing program page
493            ProgramItem lastParent = _ametysResolver.resolveById(path.get(0));
494            Program rootProgram = getRootProgram(odfRootPage, lastParent, null);
495            if (rootProgram == null)
496            {
497                // No page
498                return null;
499            }
500            
501            List<String> reversedPath = path.reversed();
502            if (checkHierarchy)
503            {
504                ProgramItem nextProgramItem = _ametysResolver.resolveById(path.getLast());
505                if (!_checkHierarchy(course, nextProgramItem))
506                {
507                    // Parent item in path is not part of program item's parents
508                    getLogger().warn(reversedPath + " is not valid hierarchy");
509                    throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
510                }
511            }
512            
513            String pagePath = _resolvePagePath(reversedPath, checkHierarchy);
514            if (pagePath == null)
515            {
516                // No page
517                return null;
518            }
519            
520            return _coursePageFactory.createCoursePage(odfRootPage, course, rootProgram, pagePath, null);
521        }
522        catch (UnknownAmetysObjectException e)
523        {
524            return null;
525        }
526    }
527    
528    /**
529     * Determines if a program page exists
530     * @param odfRootPage the ODF root page
531     * @param program the program page
532     * @return true if program page existes, false otherwise
533     */
534    public boolean isProgramPageExist(Page odfRootPage, Program program)
535    {
536        if (program == null || !_odfPageHandler.isValidRestriction(odfRootPage, program))
537        {
538            // No program page
539            return false;
540        }
541        
542        String levelsPath = _odfPageHandler.computeLevelsPath(odfRootPage, program);
543        if (levelsPath == null)
544        {
545            // The current program has no valid attributes for the levels selected in the ODF root
546            return false;
547        }
548        
549        return true;
550    }
551    
552    /**
553     * Get the ODF root page, either in the given site if it exists, or in the default ODF site.
554     * @param siteName the desired site name.
555     * @param language the sitemap language to search in.
556     * @param catalog The ODF catalog
557     * @return the ODF root page, either in the given site if it exists, or in the default ODF site.
558     */
559    public Page getOdfRootPage(String siteName, String language, String catalog)
560    {
561        Page odfRootPage = null;
562        
563        if (StringUtils.isNotEmpty(siteName))
564        {
565            odfRootPage = _odfPageHandler.getOdfRootPage(siteName, language, catalog);
566        }
567        
568        if (odfRootPage == null)
569        {
570            String odfSiteName = Config.getInstance().getValue("odf.web.site.name");
571            odfRootPage = _odfPageHandler.getOdfRootPage(odfSiteName, language, catalog);
572        }
573        
574        return odfRootPage;
575    }
576    
577    private String _resolvePagePath(List<String> reversedPath, boolean checkHierarchy)
578    {
579        // Possible reversed paths are :
580        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3, (sub)programContent://UUID]
581        // [courseContent://UUID1, courseContent://UUID2, ..., courseContent://UUID3]
582        // [courseContent://UUID1, subprogramContent://UUID2, ..., (sub)programContent://UUID3]
583        
584        Cache<String, String> cache = _cacheManager.get(__RESOLVED_PATH_CACHE);
585        return cache.get(StringUtils.join(reversedPath, ";"), item -> {
586            String pagePath = null;
587            
588            try
589            {
590                if (reversedPath.size() == 1)
591                {
592                    ProgramItem programItem = _ametysResolver.resolveById(reversedPath.get(0));
593                    pagePath = getPathInProgram(programItem, null);
594                }
595                else
596                {
597                    String parentPath = _resolvePagePath(reversedPath.subList(1, reversedPath.size()), checkHierarchy);
598                    if (parentPath != null)
599                    {
600                        ProgramItem programItem = _ametysResolver.resolveById(reversedPath.get(0));
601                        if (checkHierarchy)
602                        {
603                            // Get next parent given in path
604                            ProgramItem nextProgramItem = _ametysResolver.resolveById(reversedPath.get(1));
605                            // Check that next parent is a parent item
606                            if (!_checkHierarchy(programItem, nextProgramItem))
607                            {
608                                // Parent item in path is not part of program item's parents
609                                getLogger().warn(reversedPath + " is not valid hierarchy");
610                                throw new UnknownAmetysObjectException(reversedPath + " is not valid hierarchy");
611                            }
612                        }
613                        if (programItem instanceof AbstractProgram || programItem instanceof Course)
614                        {
615                            parentPath += '/' + _odfPageHandler.getPageName(programItem);
616                        }
617                        
618                        pagePath = parentPath;
619                    }
620                }
621            }
622            catch (UnknownAmetysObjectException e)
623            {
624                // Nothing
625            }
626            
627            cache.put(StringUtils.join(reversedPath, ";"), pagePath);
628            return pagePath;
629        });
630    }
631    
632    private boolean _checkHierarchy(ProgramItem childProgramItem, ProgramItem nextParentProgramItem)
633    {
634        List<ProgramItem> parentProgramItems = _odfHelper.getParentProgramItems(childProgramItem);
635        if (parentProgramItems.contains(nextParentProgramItem))
636        {
637            return true;
638        }
639        
640        for (ProgramItem parentProgramItem : parentProgramItems)
641        {
642            if (!(parentProgramItem instanceof AbstractProgram) && !(parentProgramItem instanceof Course) && _checkHierarchy(parentProgramItem, nextParentProgramItem))
643            {
644                return true;
645            }
646        }
647        
648        return false;
649    }
650    
651    /**
652     * Get the path in sitemap of a ODF content into a {@link Program} or {@link SubProgram}<br>
653     * Be careful, this is the path in sitemap, to get the path of a item into a Program, use {@link ODFHelper#getPathInProgram} instead.
654     * @param programItem The program item
655     * @param parentProgram The parent root (sub)program. Can be null.
656     * @return the path in sitemap from the parent program or null if no found ODF path for those program item and parent program
657     */
658    public String getPathInProgram (ProgramItem programItem, AbstractProgram parentProgram)
659    {
660        Cache<PathInProgramCacheKey, String> cache = _cacheManager.get(__PATH_IN_SITEMAP_CACHE);
661        
662        return cache.get(PathInProgramCacheKey.of(programItem.getId(), parentProgram != null ? parentProgram.getId() : "__NOPARENT"), item -> {
663            
664            if (programItem instanceof Program || programItem.equals(parentProgram))
665            {
666                // The program item is already the program it self
667                return _odfPageHandler.getPageName(programItem);
668            }
669            
670            List<String> paths = new ArrayList<>();
671
672            // Add the parent path in program if exists
673            ProgramItem parent = _odfHelper.getParentProgramItem(programItem, parentProgram);
674            if (parent != null)
675            {
676                String parentPath = getPathInProgram(parent, parentProgram);
677                if (parentPath != null)
678                {
679                    paths.add(parentPath);
680                }
681            }
682            
683            // Add the current page name (if it is an item with a page (only course, subprogram and program)
684            if (programItem instanceof AbstractProgram || programItem instanceof Course)
685            {
686                paths.add(_odfPageHandler.getPageName(programItem));
687            }
688
689            // If the path is empty, return null
690            return paths.isEmpty() ? null : StringUtils.join(paths, "/");
691        });
692    }
693    
694    private static class RootProgramCacheKey extends AbstractCacheKey
695    {
696        protected RootProgramCacheKey(Page odfRootPage, String programItemId, String parentProgramId)
697        {
698            super(odfRootPage, programItemId, parentProgramId);
699        }
700        
701        public static RootProgramCacheKey of(Page odfRootPage, String programItemId, String parentProgramId)
702        {
703            return new RootProgramCacheKey(odfRootPage, programItemId, parentProgramId);
704        }
705    }
706    
707    private static class PathInProgramCacheKey extends AbstractCacheKey
708    {
709        protected PathInProgramCacheKey(String programItemId, String parentProgramId)
710        {
711            super(programItemId, parentProgramId);
712        }
713        
714        public static PathInProgramCacheKey of(String programItemId, String parentProgramId)
715        {
716            return new PathInProgramCacheKey(programItemId, parentProgramId);
717        }
718    }
719    
720    /**
721     * 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>
722     * If 'parentProgram' is null, the first {@link Program} ancestor will be returned regardless of parent hierarchy.<br>
723     * If 'parentProgram' is a {@link SubProgram}, the first {@link Program} ancestor from this {@link SubProgram} will be returned regardless of parent hierarchy
724     * @param odfRootPage The ODf root page. Cannot be null.
725     * @param programItem a {@link ProgramItem}
726     * @param parentAbstractProgram The parent program or subprogram. Can be null.
727     * @return the parent {@link Program} into this (sub)program that matchs an existing program page, or null if not found
728     */
729    public Program getRootProgram(Page odfRootPage, ProgramItem programItem, AbstractProgram parentAbstractProgram)
730    {
731        Cache<RootProgramCacheKey, Program> rootCache = _cacheManager.get(__ODF_ROOT_PROGRAM_CACHE);
732        
733        return rootCache.get(RootProgramCacheKey.of(odfRootPage, programItem.getId(), parentAbstractProgram != null ? parentAbstractProgram.getId() : "__NOPARENT"), k -> {
734            // Get all parent programs
735            Set<Program> parentPrograms = _getParentPrograms(programItem, parentAbstractProgram);
736            
737            // Get first parent program matching an existing program page
738            Optional<Program> parentProgram = parentPrograms.stream()
739                    .filter(p -> isProgramPageExist(odfRootPage, p))
740                    .findFirst();
741            
742            return parentProgram.orElse(null);
743        });
744    }
745    
746    /**
747     * Returns all parent {@link Program} ancestors, ensuring that the given parent content 'parentProgram' is in the hierarchy, if not null.<br>
748     * If 'parentProgram' is null, the all {@link Program} ancestors will be returned regardless of parent hierarchy.<br>
749     * If 'parentProgram' is a {@link SubProgram}, the {@link Program} ancestors from this {@link SubProgram} will be returned regardless of parent hierarchy
750     * @param programItem a {@link ProgramItem}
751     * @param parentProgram The parent program or subprogram. Can be null.
752     * @return the parent {@link Program}s into this (sub)program or empty if not found
753     */
754    private Set<Program> _getParentPrograms(ProgramItem programItem, AbstractProgram parentProgram)
755    {
756        if (programItem instanceof Program program)
757        {
758            return Set.of(program);
759        }
760        
761        Set<Program> parentPrograms = new HashSet<>();
762        
763        AbstractProgram parent = parentProgram;
764        
765        List<ProgramItem> parentItems = _odfHelper.getParentProgramItems(programItem, parentProgram);
766        
767        for (ProgramItem parentItem : parentItems)
768        {
769            if (parentItem instanceof Program program)
770            {
771                parentPrograms.add(program);
772            }
773            else
774            {
775                if (parent != null && parentItem.equals(parent))
776                {
777                    // Once the desired abstract program parent is passed, the parent is null
778                    parent = null;
779                }
780                
781                parentPrograms.addAll(_getParentPrograms(parentItem, parent));
782            }
783        }
784        
785        return parentPrograms;
786    }
787    
788    /**
789     * Returns the nearest {@link AbstractProgram} ancestors.
790     * @param programPart a {@link ProgramPart}
791     * @return the nearest {@link AbstractProgram} ancestors containing this program part
792     */
793    public List<AbstractProgram> getNearestAncestorAbstractPrograms (ProgramPart programPart)
794    {
795        List<AbstractProgram> ancestors = new ArrayList<>();
796        
797        List<ProgramPart> parents = programPart.getProgramPartParents();
798        for (ProgramPart parent : parents)
799        {
800            if (parent instanceof AbstractProgram)
801            {
802                ancestors.add((AbstractProgram) parent);
803            }
804            else
805            {
806                ancestors.addAll(getNearestAncestorAbstractPrograms(parent));
807            }
808        }
809        
810        return ancestors;
811    }
812    
813    /**
814     * Returns the nearest {@link AbstractProgram} ancestor.
815     * @param programItem a {@link ProgramItem}
816     * @param parentProgram The parent program or subprogram
817     * @return the nearest {@link AbstractProgram} ancestor into this (sub)program or null if not found
818     */
819    public AbstractProgram getNearestAncestorAbstractProgram (ProgramItem programItem, AbstractProgram parentProgram)
820    {
821        ProgramItem parentItem = _odfHelper.getParentProgramItem(programItem, parentProgram);
822        while (parentItem != null && !(parentItem instanceof AbstractProgram))
823        {
824            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
825        }
826        
827        return parentItem != null ? (AbstractProgram) parentItem : null;
828    }
829    
830    /**
831     * Returns the nearest {@link Course} ancestor.
832     * @param course a {@link Course}
833     * @param parentProgram The parent program or subprogram
834     * @return the nearest {@link Course} ancestor into this (sub)program or null if not found
835     */
836    public Course getNearestAncestorCourse (Course course, AbstractProgram parentProgram)
837    {
838        ProgramItem parentItem = _odfHelper.getParentProgramItem(course, parentProgram);
839        while (parentItem != null && !(parentItem instanceof Course) && !(parentItem instanceof AbstractProgram))
840        {
841            parentItem = _odfHelper.getParentProgramItem(parentItem, parentProgram);
842        }
843        
844        return parentItem != null && parentItem instanceof Course ? (Course) parentItem : null;
845    }
846    
847    /**
848     * Get the path of a {@link ProgramItem} page into the given {@link Program}
849     * @param siteName the site name
850     * @param language the language
851     * @param programItem the subprogram.
852     * @param parentProgram The parent program
853     * @return the page path or empty if no page matches
854     */
855    public String getProgramItemPagePath(String siteName, String language, ProgramItem programItem, Program parentProgram)
856    {
857        Page rootPage = _odfPageHandler.getOdfRootPage(siteName, language, programItem.getCatalog());
858        if (rootPage != null)
859        {
860            Page page = null;
861            if (programItem instanceof Program program)
862            {
863                page = getProgramPage(rootPage, program);
864            }
865            else if (programItem instanceof SubProgram subProgram)
866            {
867                page = getSubProgramPage(rootPage, subProgram, parentProgram);
868            }
869            else if (programItem instanceof Course course)
870            {
871                page = getCoursePage(rootPage, course, parentProgram);
872            }
873            
874            if (page != null)
875            {
876                return rootPage.getSitemapName() + "/" + page.getPathInSitemap();
877            }
878        }
879        
880        return StringUtils.EMPTY;
881    }
882}