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