001/*
002 *  Copyright 2015 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.xslt;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023
024import org.apache.avalon.framework.context.Context;
025import org.apache.avalon.framework.context.ContextException;
026import org.apache.avalon.framework.context.Contextualizable;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.cocoon.components.ContextHelper;
030import org.apache.cocoon.environment.Request;
031import org.apache.commons.lang.StringUtils;
032import org.apache.commons.lang3.tuple.Pair;
033import org.w3c.dom.Element;
034import org.w3c.dom.NodeList;
035
036import org.ametys.cms.data.ContentValue;
037import org.ametys.cms.repository.Content;
038import org.ametys.cms.repository.ModifiableContent;
039import org.ametys.core.util.dom.AmetysNodeList;
040import org.ametys.core.util.dom.MapElement;
041import org.ametys.core.util.dom.StringElement;
042import org.ametys.odf.EducationalPathHelper;
043import org.ametys.odf.ProgramItem;
044import org.ametys.odf.catalog.Catalog;
045import org.ametys.odf.catalog.CatalogsManager;
046import org.ametys.odf.course.Course;
047import org.ametys.odf.data.EducationalPath;
048import org.ametys.odf.program.AbstractProgram;
049import org.ametys.odf.program.Program;
050import org.ametys.odf.program.SubProgram;
051import org.ametys.odf.skill.ODFSkillsHelper;
052import org.ametys.plugins.odfweb.repository.CoursePage;
053import org.ametys.plugins.odfweb.repository.OdfPageHandler;
054import org.ametys.plugins.odfweb.repository.OdfPageResolver;
055import org.ametys.plugins.odfweb.repository.ProgramPage;
056import org.ametys.plugins.repository.AmetysObject;
057import org.ametys.runtime.config.Config;
058import org.ametys.web.WebConstants;
059import org.ametys.web.repository.page.Page;
060import org.ametys.web.repository.sitemap.Sitemap;
061import org.ametys.web.transformation.xslt.AmetysXSLTHelper;
062
063/**
064 * Helper component to be used from XSL stylesheets.
065 */
066public class OdfXSLTHelper extends org.ametys.odf.OdfXSLTHelper implements Contextualizable
067{
068    /** The ODF page handler */
069    protected static OdfPageHandler _odfPageHandler;
070    
071    /** The ODF skills helper */
072    protected static ODFSkillsHelper _odfSkillsHelper;
073    
074    /** The ODF page resolver */
075    protected static OdfPageResolver _odfPageResolver;
076
077    /** The catalogs manager */
078    protected static CatalogsManager _catalogsManager;
079    
080    /** The avalon context */
081    protected static Context _context;
082    
083    /**
084     * Enum result to determine if a path is part of the current context
085     */
086    public static enum EducationaPathContextResult
087    {
088        /** If the path does not belong to the current context */
089        FALSE,
090        /** If the path does belong to the current context but we can't exactly determined the context */
091        TRUE,
092        /** If the path does belong to the current context */
093        TRUE_EXACTLY
094    }
095    
096    @Override
097    public void service(ServiceManager smanager) throws ServiceException
098    {
099        super.service(smanager);
100        _odfPageHandler = (OdfPageHandler) smanager.lookup(OdfPageHandler.ROLE);
101        _odfSkillsHelper = (ODFSkillsHelper) smanager.lookup(ODFSkillsHelper.ROLE);
102        _odfPageResolver = (OdfPageResolver) smanager.lookup(OdfPageResolver.ROLE);
103        _catalogsManager = (CatalogsManager) smanager.lookup(CatalogsManager.ROLE);
104    }
105    
106    public void contextualize(Context context) throws ContextException
107    {
108        _context = context;
109    }
110    
111    /**
112     * Get the ODF root page, for a specific site, language.
113     * If there is many ODF root pages, the first page of the list is returned.
114     * @param siteName the desired site name.
115     * @param language the sitemap language to search in.
116     * @return the first ODF root page, or null if not found
117     */
118    public static String odfRootPage(String siteName, String language)
119    {
120        Page odfRootPage = _odfPageHandler.getOdfRootPage(siteName, language);
121        if (odfRootPage != null)
122        {
123            return odfRootPage.getId();
124        }
125        return null;
126    }
127    
128    /**
129     * Get the ODF root page, for a specific site, language and catalog.
130     * @param siteName the desired site name.
131     * @param language the sitemap language to search in.
132     * @param catalog The ODF catalog
133     * @return the ODF root page, or null if not found
134     */
135    public static String odfRootPage(String siteName, String language, String catalog)
136    {
137        Page odfRootPage = _odfPageHandler.getOdfRootPage(siteName, language, catalog);
138        if (odfRootPage != null)
139        {
140            return odfRootPage.getId();
141        }
142        return null;
143    }
144    
145    /**
146     * Get the PDF url of a program or a subprogram
147     * @param contentId The content id
148     * @param siteName The site name
149     * @return the PDF url or empty string if the content is not a {@link Program} or {@link SubProgram}
150     */
151    public static String odfPDFUrl (String contentId, String siteName)
152    {
153        StringBuilder sb = new StringBuilder();
154        
155        Content content = _ametysObjectResolver.resolveById(contentId);
156        if (content instanceof AbstractProgram)
157        {
158            sb.append(AmetysXSLTHelper.uriPrefix())
159                .append("/plugins/odf-web/")
160                .append(siteName)
161                .append("/_content/")
162                .append(content.getName())
163                .append(".pdf");
164        }
165        
166        return sb.toString();
167    }
168
169    /**
170     * Get the id of parent program from the current page
171     * @return the id of parent program or null if not found
172     */
173    public static String parentProgramId()
174    {
175        String pageId = AmetysXSLTHelper.pageId();
176        
177        if (StringUtils.isNotEmpty(pageId))
178        {
179            Page page = _ametysObjectResolver.resolveById(pageId);
180            
181            AmetysObject parent = page.getParent();
182            while (!(parent instanceof Sitemap))
183            {
184                if (parent instanceof ProgramPage)
185                {
186                    return ((ProgramPage) parent).getProgram().getId();
187                }
188                
189                parent = parent.getParent();
190            }
191        }
192        
193        return null;
194    }
195    
196    /**
197     * Get the ECTS of the current course for the current context if present
198     * @return the ECTS or 0 if not found
199     */
200    public static double getCurrentEcts()
201    {
202        Pair<ProgramItem, List<EducationalPath>> currentEducationalPaths = _getCurrentEducationalPaths();
203        if (currentEducationalPaths != null)
204        {
205            ProgramItem programItem = currentEducationalPaths.getLeft();
206            if (programItem instanceof Course course)
207            {
208                return course.getEcts(currentEducationalPaths.getRight());
209            }
210        }
211        
212        return 0;
213    }
214    
215    /**
216     * Get the Skills of the current course for the current context if present
217     * @return the MicroSkills by MacroSkills or null if not found
218     */
219    public static NodeList getCurrentSkills()
220    {
221        List<Element> result = new ArrayList<>();
222        
223        // If skills not enabled, return empty results
224        boolean isSkillsEnabled = Config.getInstance().getValue("odf.skills.enabled");
225        if (!isSkillsEnabled)
226        {
227            return new AmetysNodeList(result);
228        }
229        
230        Pair<ProgramItem, List<EducationalPath>> currentEducationalPaths = _getCurrentEducationalPaths();
231        if (currentEducationalPaths != null)
232        {
233            ProgramItem programItem = currentEducationalPaths.getLeft();
234            List<EducationalPath> educationalPath = currentEducationalPaths.getRight();
235            if (programItem instanceof Course course && !educationalPath.isEmpty())
236            {
237                EducationalPath programEducationalPath = educationalPath.getFirst();
238                String programId = programEducationalPath.getProgramItemIds().get(0);
239                ContentValue[] skillsValues = course.getAcquiredSkills(programId);
240
241                Map<String, Element> createdMacroSkillNode = new HashMap<>();
242
243                for (ContentValue skillValue : skillsValues)
244                {
245                    Optional<ModifiableContent> optionalContent = skillValue.getContentIfExists();
246                    if (optionalContent.isPresent())
247                    {
248                        Content skillContent = optionalContent.get();
249
250                        ContentValue parentMacroSkillValue = skillContent.getValue("parentMacroSkill");
251                        if (parentMacroSkillValue != null)
252                        {
253                            Optional<ModifiableContent> optionalParentMacroSkill = parentMacroSkillValue.getContentIfExists();
254                            if (optionalParentMacroSkill.isPresent())
255                            {
256                                ModifiableContent parentMacroSkill = optionalParentMacroSkill.get();
257                                
258                                String parentMacroSkillId = parentMacroSkill.getId();
259                                Map<String, String> macroSkillMap = new HashMap<>();
260                                if (!createdMacroSkillNode.containsKey(parentMacroSkillId))
261                                {
262                                    macroSkillMap.put("id", parentMacroSkillId);
263                                    macroSkillMap.put("title", parentMacroSkill.getTitle());
264                                    macroSkillMap.put("type", "MACROSKILL");
265                                    macroSkillMap.put("parentProgram", programId);
266                                    
267                                    Element parentNode = new StringElement("skill", macroSkillMap);
268                                    
269                                    createdMacroSkillNode.put(parentMacroSkillId, parentNode);
270                                    
271                                    result.add(parentNode);
272                                }
273                                
274                                Map<String, String> skillMap = new HashMap<>();
275                                skillMap.put("id", skillContent.getId());
276                                skillMap.put("title", skillContent.getTitle());
277                                skillMap.put("type", "MICROSKILL");
278                                skillMap.put("parent", parentMacroSkillId);
279                                
280                                Element node = new StringElement("skill", skillMap);
281                                result.add(node);
282                            }
283                        }
284                    }
285                }
286            }
287        }
288
289        return new AmetysNodeList(result);
290    }
291    
292    private static Pair<ProgramItem, List<EducationalPath>> _getCurrentEducationalPaths()
293    {
294        Request request = ContextHelper.getRequest(_context);
295        Page page = (Page) request.getAttribute(WebConstants.REQUEST_ATTR_PAGE);
296        
297        // First try to get current educational paths from course page if present
298        if (page != null)
299        {
300            if (page instanceof CoursePage coursePage)
301            {
302                Course course = coursePage.getContent();
303                return Pair.of(course, course.getCurrentEducationalPaths());
304            }
305            else if (page instanceof ProgramPage programPage)
306            {
307                AbstractProgram abstractProgram = programPage.getContent();
308                return Pair.of(abstractProgram, abstractProgram.getCurrentEducationalPaths());
309            }
310        }
311        
312        // Then try to get current educational paths from course content if present
313        Content content = (Content) request.getAttribute(Content.class.getName());
314        return _getCurrentEducationalPaths(content);
315    }
316    
317    private static Pair<ProgramItem, List<EducationalPath>> _getCurrentEducationalPaths(Content content)
318    {
319        Request request = ContextHelper.getRequest(_context);
320        if (content != null && (content instanceof Course || content instanceof AbstractProgram))
321        {
322            // First try to get educational paths from content
323            List<EducationalPath> currentEducationalPaths = content instanceof Course course ? course.getCurrentEducationalPaths() : ((AbstractProgram) content).getCurrentEducationalPaths();
324            if (currentEducationalPaths == null)
325            {
326                // If null try to get educational paths from request attributes
327                @SuppressWarnings("unchecked")
328                List<ProgramItem> pathFromRequest = (List<ProgramItem>) request.getAttribute(EducationalPathHelper.PROGRAM_ITEM_ANCESTOR_PATH_REQUEST_ATTR);
329                if (pathFromRequest != null)
330                {
331                    // In request the path may be a partial path
332                    currentEducationalPaths = _odfHelper.getEducationPathFromPath(pathFromRequest);
333                    
334                    // If ancestor is present in request attribute, filter paths that contains this ancestor
335                    ProgramItem ancestor = (ProgramItem) request.getAttribute(EducationalPathHelper.ROOT_PROGRAM_ITEM_REQUEST_ATTR);
336                    if (ancestor != null)
337                    {
338                        currentEducationalPaths = currentEducationalPaths.stream()
339                                .filter(p -> p.getProgramItemIds().contains(ancestor.getId()))
340                                .toList();
341                    }
342                }
343                else
344                {
345                    // Cannot determine current educational paths from context, returns all available education paths
346                    currentEducationalPaths = _odfHelper.getEducationalPaths((ProgramItem) content, true, true);
347                }
348            }
349            
350            return Pair.of((ProgramItem) content, currentEducationalPaths);
351        }
352        
353        return null;
354    }
355    
356    /**
357     * Determines if a given {@link EducationalPath} is part of the current {@link EducationalPath}s<br>
358     * - TRUE_EXACTLY if the education path belongs to the current educational path and the current educational path is unique
359     * - TRUE if the education path belongs to the current educational paths
360     * - FALSE if the education path does not belong to the current educational paths
361     * @param educationPath the education path as string
362     * @return the status of the entry path belonging to the current context or not
363     */
364    public static String isPartOfCurrentEducationalPaths(String educationPath)
365    {
366        return _isPartOfCurrentEducationalPaths(educationPath).name();
367    }
368    
369    private static EducationaPathContextResult _isPartOfCurrentEducationalPaths(String educationPath)
370    {
371        EducationalPath educationalPath = EducationalPath.of(educationPath.split(EducationalPath.PATH_SEGMENT_SEPARATOR));
372        
373        List<EducationalPath> currentEducationalPaths = _getCurrentEducationalPaths().getRight();
374        if (currentEducationalPaths.contains(educationalPath))
375        {
376            return currentEducationalPaths.size() > 1
377                    ? EducationaPathContextResult.TRUE // Cannot determine the current context precisely
378                    : EducationaPathContextResult.TRUE_EXACTLY; // Only one educational path possible
379        }
380        
381        return EducationaPathContextResult.FALSE; // no part of current education paths
382    }
383    
384    /**
385     * Extract educational path of main structure that the given education path belongs to. The path stops to the last parent container of type year or the last subprogram if the year does not exist.
386     * @param educationalPath the educational path as string
387     * @return the readable education path of main structure
388     */
389    public static String getMainStructureEducationalPathAsString(String educationalPath)
390    {
391        EducationalPath educationPath = EducationalPath.of(educationalPath.split(EducationalPath.PATH_SEGMENT_SEPARATOR));
392        return educationPath != null ? _odfHelper.getEducationalPathAsString(educationPath, pi -> ((Content) pi).getTitle(), " > ", pi -> _filterProgramItemInPath(pi)) : null;
393    }
394    
395    private static boolean _filterProgramItemInPath(ProgramItem p)
396    {
397        return p instanceof AbstractProgram || _odfHelper.isContainerOfTypeYear((Content) p);
398    }
399    
400    /**
401     * Get the ECTS of the current course for the current context if present
402     * @param defaultValue The default value
403     * @return the ECTS or 0 if not found
404     */
405    public static double getCurrentEcts(String defaultValue)
406    {
407        double currentEcts = getCurrentEcts();
408        return currentEcts != 0 ? currentEcts : (StringUtils.isNotEmpty(defaultValue) ? Double.valueOf(defaultValue) : 0);
409    }
410    
411    /**
412     * Determines if the values of ECTS is equals for the course's educational paths in the current context
413     * @param courseId The course id
414     * @return true if the values of ECTS is equals in the current context
415     */
416    public static boolean areECTSEqual(String courseId)
417    {
418        Course course = _ametysObjectResolver.resolveById(courseId);
419        return _areECTSEqual(course);
420    }
421    
422    /**
423     * Determines if the values of ECTS is equals for the current course's educational paths in the current context
424     * @return true if the values of ECTS is equals in the current context
425     */
426    public static boolean areECTSEqual()
427    {
428        Request request = ContextHelper.getRequest(_context);
429        Content content = (Content) request.getAttribute(Content.class.getName());
430        if (content != null && content instanceof Course course)
431        {
432            return _areECTSEqual(course);
433        }
434        return false;
435    }
436    
437    private static boolean _areECTSEqual(Course course)
438    {
439        Pair<ProgramItem, List<EducationalPath>> currentEducationalPaths = _getCurrentEducationalPaths(course);
440        if (currentEducationalPaths != null)
441        {
442            ProgramItem programItem = currentEducationalPaths.getLeft();
443            List<EducationalPath> paths = currentEducationalPaths.getRight();
444            return paths != null ? _odfHelper.isSameValueForPaths(programItem, Course.ECTS_BY_PATH, paths) : _odfHelper.isSameValueForAllPaths(programItem, Course.ECTS_BY_PATH);
445        }
446        else
447        {
448            return _odfHelper.isSameValueForAllPaths(course, Course.ECTS_BY_PATH);
449        }
450    }
451    
452    /**
453     * <code>true</code> if the program item is part of an program item (program, subprogram or container) that is excluded from skills
454     * @param programItemId the program item id
455     * @param programPageItemId the program item page id. If null or empty, program item is display with no context, consider that skills are available
456     * @return <code>true</code> if the program item has an excluded parent in it path from the page context
457     */
458    public static boolean areSkillsUnavailable(String programItemId, String programPageItemId)
459    {
460        if (StringUtils.isBlank(programItemId) || StringUtils.isBlank(programPageItemId))
461        {
462            // program part is displayed outside a page context, assuming that skills should be displayed
463            return false;
464        }
465        
466        ProgramItem programItem = _ametysObjectResolver.resolveById(programItemId);
467        if (programItem instanceof Program)
468        {
469            return _odfSkillsHelper.isExcluded(programItem);
470        }
471        
472        Page programItemPage = _ametysObjectResolver.resolveById(programPageItemId);
473        
474        ProgramPage closestProgramPage = _getClosestProgramPage(programItemPage);
475        AbstractProgram closestProgramOrSubprogram = closestProgramPage.getProgram();
476        
477        ProgramItem parent = _odfHelper.getParentProgramItem(programItem, closestProgramOrSubprogram);
478        while (parent != null && !(parent instanceof Program))
479        {
480            if (_odfSkillsHelper.isExcluded(parent))
481            {
482                // If the parent is excluded, the skills are unavailable
483                return true;
484            }
485            
486            // If the closest program parent is a subprogram, continue to its program parent
487            if (closestProgramOrSubprogram instanceof SubProgram && closestProgramOrSubprogram.equals(parent))
488            {
489                closestProgramOrSubprogram = ((ProgramPage) closestProgramPage.getParent()).getProgram();
490            }
491            parent = _odfHelper.getParentProgramItem(parent, closestProgramOrSubprogram);
492        }
493        
494        return parent != null ? _odfSkillsHelper.isExcluded(parent) : false;
495    }
496    
497    private static ProgramPage _getClosestProgramPage(Page page)
498    {
499        Page parentPage = page.getParent();
500        while (!(parentPage instanceof ProgramPage))
501        {
502            parentPage = parentPage.getParent();
503        }
504        
505        return (ProgramPage) parentPage;
506    }
507    
508    /**
509     * Get the pages of program items with the same code.
510     * @param programItemId The program item identifier
511     * @param lang The language
512     * @param siteName The site name
513     * @return the list of pages holding program items with the same code (one by catalog max)
514     */
515    public static NodeList getProgramItemPages(String programItemId, String lang, String siteName)
516    {
517        ProgramItem currentProgramItem = _ametysObjectResolver.resolveById(programItemId);
518        
519        List<Element> pages = new ArrayList<>();
520        
521        // Get all existing contents with this code
522        for (Content content : _odfHelper.getProgramItems(List.of(), currentProgramItem.getCode(), null, lang))
523        {
524            if (content instanceof ProgramItem programItem)
525            {
526                // Get the catalog of the program item
527                Catalog catalog = _catalogsManager.getCatalog(programItem.getCatalog());
528                
529                // Retrieve all pages referencing this content in the given sitename and language
530                _odfPageResolver.getReferencingPages(programItem, siteName, lang)
531                    .stream()
532                    // Retain only the first one
533                    .findFirst()
534                    // Build the values to return
535                    .map(Page::getId)
536                    .map(pageId ->
537                        new MapElement(
538                            "page",
539                            Map.of("isCurrentCatalog", String.valueOf(programItem.getCatalog().equals(currentProgramItem.getCatalog()))),
540                            Map.of(
541                                "pageId", pageId,
542                                "catalog", catalog.getTitle()
543                            )
544                        )
545                    )
546                    // Only add it if exists
547                    .ifPresent(pages::add);
548            }
549        }
550        
551        return new AmetysNodeList(pages);
552    }
553}