001/*
002 *  Copyright 2019 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.odf.content;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023import java.util.Set;
024import java.util.regex.Pattern;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.parameters.Parameters;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.cocoon.ProcessingException;
031import org.apache.cocoon.environment.ObjectModelHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.cocoon.generation.ServiceableGenerator;
034import org.apache.cocoon.xml.AttributesImpl;
035import org.apache.cocoon.xml.SaxBuffer;
036import org.apache.cocoon.xml.XMLUtils;
037import org.apache.commons.lang.StringUtils;
038import org.apache.commons.lang3.LocaleUtils;
039import org.apache.commons.lang3.tuple.Triple;
040import org.xml.sax.SAXException;
041
042import org.ametys.cms.contenttype.ContentTypesHelper;
043import org.ametys.cms.repository.Content;
044import org.ametys.core.cache.AbstractCacheManager;
045import org.ametys.core.cache.Cache;
046import org.ametys.odf.EducationalPathHelper;
047import org.ametys.odf.NoLiveVersionException;
048import org.ametys.odf.ODFHelper;
049import org.ametys.odf.ProgramItem;
050import org.ametys.odf.course.Course;
051import org.ametys.odf.courselist.CourseList;
052import org.ametys.odf.coursepart.CoursePart;
053import org.ametys.odf.data.EducationalPath;
054import org.ametys.odf.enumeration.OdfReferenceTableEntry;
055import org.ametys.odf.enumeration.OdfReferenceTableHelper;
056import org.ametys.odf.program.AbstractProgram;
057import org.ametys.odf.program.Container;
058import org.ametys.odf.program.Program;
059import org.ametys.odf.program.SubProgram;
060import org.ametys.odf.skill.ODFSkillsHelper;
061import org.ametys.plugins.repository.AmetysObjectResolver;
062import org.ametys.plugins.repository.AmetysRepositoryException;
063import org.ametys.plugins.repository.jcr.DefaultAmetysObject;
064import org.ametys.runtime.i18n.I18nizableText;
065import org.ametys.runtime.model.View;
066import org.ametys.runtime.model.exception.BadItemTypeException;
067import org.ametys.runtime.model.type.DataContext;
068
069/**
070 * SAX the structure (ie. the child program items) of a {@link ProgramItem}
071 *
072 */
073public class ProgramItemStructureGenerator extends ServiceableGenerator implements Initializable
074{
075    // FIXME Accept all view names starting with "main" to be able to make the ODF and ODF orientation skins coexist (whereas they use different main views)
076    private static final Set<Pattern> __ALLOWED_VIEW_NAMES = Set.of(Pattern.compile("main[a-z\\-]*"), Pattern.compile("pdf"));
077    
078    private static final String __REF_ITEM_CACHE_ID = ProgramItemStructureGenerator.class.getName() + "$refItems";
079    private static final String __VIEW_CACHE_ID = ProgramItemStructureGenerator.class.getName() + "$view";
080    private static final String __COMMON_ATTRIBUTES_CACHE_ID = ProgramItemStructureGenerator.class.getName() + "$commonAttributes";
081    
082    /** The ODF helper */
083    protected ODFHelper _odfHelper;
084    /** Helper for ODF reference table */
085    protected OdfReferenceTableHelper _odfReferenceTableHelper;
086    /** The content types helper */
087    protected ContentTypesHelper _cTypesHelper;
088    /** The ODF skills helper */
089    protected ODFSkillsHelper _odfSkillsHelper;
090    /** The Ametys object resolver */
091    protected AmetysObjectResolver _resolver;
092    /** The cache manager */
093    protected AbstractCacheManager _cacheManager;
094    
095    private Cache<Triple<String, String, String>, SaxBuffer> _refItemCache;
096    private Cache<Content, SaxBuffer> _viewCache;
097    private Cache<ProgramItem, CommonAttributes> _commonAttrCache;
098    
099    @Override
100    public void service(ServiceManager smanager) throws ServiceException
101    {
102        _odfHelper = (ODFHelper) smanager.lookup(ODFHelper.ROLE);
103        _odfReferenceTableHelper = (OdfReferenceTableHelper) smanager.lookup(OdfReferenceTableHelper.ROLE);
104        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
105        _odfSkillsHelper = (ODFSkillsHelper) smanager.lookup(ODFSkillsHelper.ROLE);
106        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
107        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
108    }
109    
110    @Override
111    public void setup(org.apache.cocoon.environment.SourceResolver res, Map objModel, String src, Parameters par) throws ProcessingException, SAXException, IOException
112    {
113        super.setup(res, objModel, src, par);
114        _refItemCache = _cacheManager.get(__REF_ITEM_CACHE_ID);
115        _viewCache = _cacheManager.get(__VIEW_CACHE_ID);
116        _commonAttrCache = _cacheManager.get(__COMMON_ATTRIBUTES_CACHE_ID);
117    }
118    
119    @Override
120    public void recycle()
121    {
122        super.recycle();
123        _refItemCache = null;
124        _viewCache = null;
125        _commonAttrCache = null;
126    }
127    
128    public void initialize() throws Exception
129    {
130        if (!_cacheManager.hasCache(__REF_ITEM_CACHE_ID))
131        {
132            _cacheManager.createRequestCache(__REF_ITEM_CACHE_ID,
133                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_REF_ITEMS_LABEL"),
134                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_REF_ITEMS_DESCRIPTION"),
135                                      false);
136        }
137        
138        if (!_cacheManager.hasCache(__VIEW_CACHE_ID))
139        {
140            _cacheManager.createRequestCache(__VIEW_CACHE_ID,
141                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_VIEW_LABEL"),
142                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_VIEW_DESCRIPTION"),
143                                      false);
144        }
145        
146        if (!_cacheManager.hasCache(__COMMON_ATTRIBUTES_CACHE_ID))
147        {
148            _cacheManager.createRequestCache(__COMMON_ATTRIBUTES_CACHE_ID,
149                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_COMMON_ATTRIBUTES_LABEL"),
150                                      new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_PROGRAM_ITEM_STRUCTURE_COMMON_ATTRIBUTES_DESCRIPTION"),
151                                      false);
152        }
153    }
154    
155    public void generate() throws IOException, SAXException, ProcessingException
156    {
157        Request request = ObjectModelHelper.getRequest(objectModel);
158        Content content = (Content) request.getAttribute(Content.class.getName());
159        
160        if (content == null)
161        {
162            String contentId = parameters.getParameter("contentId", null);
163            if (StringUtils.isBlank(contentId))
164            {
165                throw new IllegalArgumentException("Content is missing in request attribute or parameters");
166            }
167            content = _resolver.resolveById(contentId);
168        }
169
170        String viewName = parameters.getParameter("viewName", StringUtils.EMPTY);
171        String fallbackViewName = parameters.getParameter("fallbackViewName", StringUtils.EMPTY);
172        
173        View view = _cTypesHelper.getViewWithFallback(viewName, fallbackViewName, content);
174        
175        contentHandler.startDocument();
176        
177        if (view != null && __ALLOWED_VIEW_NAMES.stream().anyMatch(p -> p.matcher(view.getName()).matches()))
178        {
179            if (content instanceof ProgramItem programItem)
180            {
181                XMLUtils.startElement(contentHandler, "structure");
182                
183                List<ProgramItem> initialAncestorPath = _getInitialAncestorPath(request, programItem);
184                saxProgramItem(programItem, initialAncestorPath);
185                
186                XMLUtils.endElement(contentHandler, "structure");
187            }
188            else
189            {
190                getLogger().warn("Cannot get the structure of a non program item '" + content.getId() + "'");
191            }
192        }
193        
194        contentHandler.endDocument();
195    }
196    
197    /**
198     * Get the initial ancestor path from request or from root program item
199     * @param request the request
200     * @param rootProgramItem the root program item in saxed structure
201     * @return the initial ancestor path as a list of program items
202     */
203    protected List<ProgramItem> _getInitialAncestorPath(Request request, ProgramItem rootProgramItem)
204    {
205        // First try to get ancestor path given by request
206        @SuppressWarnings("unchecked")
207        List<ProgramItem> ancestorPath = (List<ProgramItem>) request.getAttribute(EducationalPathHelper.PROGRAM_ITEM_ANCESTOR_PATH_REQUEST_ATTR);
208        
209        if (ancestorPath == null)
210        {
211            if (rootProgramItem instanceof SubProgram subProgram)
212            {
213                List<EducationalPath> subProgramPaths = subProgram.getCurrentEducationalPaths();
214                if (subProgramPaths != null && subProgramPaths.size() == 1)
215                {
216                    // Init the ancestor paths from current educational path only if there is only one eligible educational path
217                    ancestorPath = subProgramPaths.get(0).getProgramItems(_resolver);
218                }
219            }
220            else if (rootProgramItem instanceof Course course)
221            {
222                List<EducationalPath> coursePaths = course.getCurrentEducationalPaths();
223                if (coursePaths != null && coursePaths.size() == 1)
224                {
225                    // Init the ancestor paths from current educational path only if there is only one eligible educational path
226                    ancestorPath = coursePaths.get(0).getProgramItems(_resolver);
227                }
228            }
229        }
230        
231        // Ancestor path cannot be determine by context, initialize the ancestor path to a item itself
232        return ancestorPath == null || ancestorPath.isEmpty() ? List.of(rootProgramItem) : ancestorPath;
233    }
234        
235    /**
236     * SAX a program item with its child program items
237     * @param programItem the program item
238     * @param ancestorPath The path of this program item in the saxed structure (computed from the initial saxed program item). Can be a partial path.
239     * @throws SAXException if an error occurs while saxing
240     */
241    protected void saxProgramItem(ProgramItem programItem, List<ProgramItem> ancestorPath) throws SAXException
242    {
243        if (programItem instanceof Program)
244        {
245            saxProgram((Program) programItem);
246        }
247        else if (programItem instanceof SubProgram)
248        {
249            saxSubProgram((SubProgram) programItem, ancestorPath);
250        }
251        else if (programItem instanceof Container)
252        {
253            saxContainer((Container) programItem, ancestorPath);
254        }
255        else if (programItem instanceof CourseList)
256        {
257            saxCourseList((CourseList) programItem, ancestorPath);
258        }
259        else if (programItem instanceof Course)
260        {
261            saxCourse((Course) programItem, ancestorPath);
262        }
263    }
264    
265    /**
266     * SAX the child program items
267     * @param programItem the program item
268     * @param ancestorPath The path of parent program item in the structure (starting from the initial saxed program item)
269     * @throws SAXException if an error occurs while saxing
270     */
271    protected void saxChildProgramItems(ProgramItem programItem, List<ProgramItem> ancestorPath) throws SAXException
272    {
273        List<ProgramItem> childProgramItems = _odfHelper.getChildProgramItems(programItem);
274        for (ProgramItem childProgramItem : childProgramItems)
275        {
276            try
277            {
278                _odfHelper.switchToLiveVersionIfNeeded((DefaultAmetysObject) childProgramItem);
279                List<ProgramItem> childAncestorPath = new ArrayList<>(ancestorPath);
280                childAncestorPath.add(childProgramItem);
281                
282                saxProgramItem(childProgramItem, childAncestorPath);
283            }
284            catch (NoLiveVersionException e)
285            {
286                // Just ignore the program item
287            }
288        }
289    }
290        
291    /**
292     * SAX a program
293     * @param program the subprogram to SAX
294     * @throws SAXException if an error occurs
295     */
296    protected void saxProgram(Program program) throws SAXException
297    {
298        AttributesImpl attrs = new AttributesImpl();
299        _saxCommonAttributes(program, null, attrs);
300        
301        XMLUtils.startElement(contentHandler, "program", attrs);
302        
303        XMLUtils.startElement(contentHandler, "attributes");
304        _saxStructureViewIfExists(program);
305        XMLUtils.endElement(contentHandler, "attributes");
306        
307        saxChildProgramItems(program, List.of(program));
308        XMLUtils.endElement(contentHandler, "program");
309    }
310    
311    /**
312     * SAX a subprogram
313     * @param subProgram the subprogram to SAX
314     * @param ancestorPath The path of this program item in the saxed structure (computed from the initial saxed program item). Can be a partial path.
315     * @throws SAXException if an error occurs
316     */
317    protected void saxSubProgram(SubProgram subProgram, List<ProgramItem> ancestorPath) throws SAXException
318    {
319        AttributesImpl attrs = new AttributesImpl();
320        _saxCommonAttributes(subProgram, ancestorPath, attrs);
321        
322        XMLUtils.startElement(contentHandler, "subprogram", attrs);
323        
324        XMLUtils.startElement(contentHandler, "attributes");
325        _saxReferenceTableItem(subProgram.getEcts(), AbstractProgram.ECTS, subProgram.getLanguage());
326        _saxStructureViewIfExists(subProgram);
327        XMLUtils.endElement(contentHandler, "attributes");
328        
329        saxChildProgramItems(subProgram, ancestorPath);
330        
331        XMLUtils.endElement(contentHandler, "subprogram");
332    }
333    
334    /**
335     * SAX a container
336     * @param container the container to SAX
337     * @param ancestorPath The path of this program item in the saxed structure (computed from the initial saxed program item). Can be a partial path.
338     * @throws SAXException if an error occurs while saxing
339     */
340    protected void saxContainer(Container container, List<ProgramItem> ancestorPath) throws SAXException
341    {
342        AttributesImpl attrs = new AttributesImpl();
343        _saxCommonAttributes(container, ancestorPath, attrs);
344        
345        XMLUtils.startElement(contentHandler, "container", attrs);
346        
347        XMLUtils.startElement(contentHandler, "attributes");
348        _saxReferenceTableItem(container.getNature(), Container.NATURE, container.getLanguage());
349        _saxReferenceTableItem(container.getPeriod(), Container.PERIOD, container.getLanguage());
350        
351        _saxStructureViewIfExists(container);
352        
353        XMLUtils.endElement(contentHandler, "attributes");
354        
355        saxChildProgramItems(container, ancestorPath);
356        
357        XMLUtils.endElement(contentHandler, "container");
358    }
359    
360    /**
361     * SAX a course list
362     * @param cl the course list to SAX
363     * @param ancestorPath The path of this program item in the saxed structure (computed from the initial saxed program item). Can be a partial path.
364     * @throws SAXException if an error occurs while saxing
365     */
366    protected void saxCourseList(CourseList cl, List<ProgramItem> ancestorPath) throws SAXException
367    {
368        AttributesImpl attrs = new AttributesImpl();
369        _saxCommonAttributes(cl, ancestorPath, attrs);
370        
371        XMLUtils.startElement(contentHandler, "courselist", attrs);
372        
373        XMLUtils.startElement(contentHandler, "attributes");
374        _saxStructureViewIfExists(cl);
375        XMLUtils.endElement(contentHandler, "attributes");
376        
377        saxChildProgramItems(cl, ancestorPath);
378        
379        XMLUtils.endElement(contentHandler, "courselist");
380    }
381    
382    /**
383     * SAX a course
384     * @param course the container to SAX
385     * @param ancestorPath The path of this program item in the saxed structure (computed from the initial saxed program item). Can be a partial path.
386     * @throws SAXException if an error occurs while saxing
387     */
388    protected void saxCourse(Course course, List<ProgramItem> ancestorPath) throws SAXException
389    {
390        AttributesImpl attrs = new AttributesImpl();
391        _saxCommonAttributes(course, ancestorPath, attrs);
392        
393        XMLUtils.startElement(contentHandler, "course", attrs);
394        
395        XMLUtils.startElement(contentHandler, "attributes");
396        _saxReferenceTableItem(course.getCourseType(), Course.COURSE_TYPE, course.getLanguage());
397        _saxStructureViewIfExists(course);
398        XMLUtils.endElement(contentHandler, "attributes");
399        
400        saxChildProgramItems(course, ancestorPath);
401        
402        saxCourseParts(course);
403        
404        XMLUtils.endElement(contentHandler, "course");
405    }
406    
407    /**
408     * SAX a course part
409     * @param course The course
410     * @throws SAXException if an error occurs
411     */
412    protected void saxCourseParts(Course course) throws SAXException
413    {
414        List<CoursePart> courseParts = course.getCourseParts();
415        
416        double totalHours = 0;
417        List<CoursePart> liveCourseParts = new ArrayList<>();
418        for (CoursePart coursePart : courseParts)
419        {
420            try
421            {
422                _odfHelper.switchToLiveVersionIfNeeded(coursePart);
423                totalHours += coursePart.getNumberOfHours();
424                liveCourseParts.add(coursePart);
425            }
426            catch (NoLiveVersionException e)
427            {
428                getLogger().warn("Some hours of " + course.toString() + " are not added because the course part " + coursePart.toString() + " does not have a live version.");
429            }
430        }
431        
432        AttributesImpl attrs = new AttributesImpl();
433        attrs.addCDATAAttribute("totalHours", String.valueOf(totalHours));
434        XMLUtils.startElement(contentHandler, "courseparts", attrs);
435
436        for (CoursePart coursePart : liveCourseParts)
437        {
438            saxCoursePart(coursePart);
439        }
440        
441        XMLUtils.endElement(contentHandler, "courseparts");
442    }
443    
444    /**
445     * SAX a course part
446     * @param coursePart The course part to SAX
447     * @throws SAXException if an error occurs
448     */
449    protected void saxCoursePart(CoursePart coursePart) throws SAXException
450    {
451        AttributesImpl attrs = new AttributesImpl();
452        attrs.addCDATAAttribute("id", coursePart.getId());
453        attrs.addCDATAAttribute("title", coursePart.getTitle());
454        _addAttrIfNotEmpty(attrs, "code", coursePart.getCode());
455
456        XMLUtils.startElement(contentHandler, "coursepart", attrs);
457        
458        XMLUtils.startElement(contentHandler, "attributes");
459        _saxReferenceTableItem(coursePart.getNature(), CoursePart.NATURE, coursePart.getLanguage());
460        
461        _saxStructureViewIfExists(coursePart);
462        
463        XMLUtils.endElement(contentHandler, "attributes");
464        
465        XMLUtils.endElement(contentHandler, "coursepart");
466    }
467    
468    /**
469     * SAX the 'structure' view if exists
470     * @param content the content
471     * @throws SAXException if an error occurs
472     */
473    protected void _saxStructureViewIfExists(Content content) throws SAXException
474    {
475        SaxBuffer buffer = _viewCache.get(content);
476        
477        if (buffer != null)
478        {
479            buffer.toSAX(contentHandler);
480            return;
481        }
482        
483        View view = _cTypesHelper.getView("structure", content.getTypes(), content.getMixinTypes());
484        if (view != null)
485        {
486            try
487            {
488                buffer = new SaxBuffer();
489                
490                content.dataToSAX(buffer, view, DataContext.newInstance().withLocale(LocaleUtils.toLocale(content.getLanguage())).withEmptyValues(false));
491                
492                _viewCache.put(content, buffer);
493                buffer.toSAX(contentHandler);
494            }
495            catch (BadItemTypeException | AmetysRepositoryException e)
496            {
497                throw new SAXException("Fail to sax the 'structure' view for content " + content.getId(), e);
498            }
499        }
500    }
501    
502    /**
503     * SAX the common attributes for program item
504     * @param programItem the program item
505     * @param ancestorPath The path of this program item in the structure (starting from the initial saxed program item)
506     * @param attrs the attributes
507     */
508    protected void _saxCommonAttributes(ProgramItem programItem, List<ProgramItem> ancestorPath, AttributesImpl attrs)
509    {
510        CommonAttributes commonAttributes = _commonAttrCache.get(programItem, k -> {
511            return new CommonAttributes(programItem.getId(), ((Content) programItem).getTitle(), programItem.getCode(), programItem.getName(), _odfSkillsHelper.isExcluded(programItem));
512        });
513        
514        attrs.addCDATAAttribute("title", commonAttributes.title());
515        attrs.addCDATAAttribute("id", commonAttributes.id());
516        attrs.addCDATAAttribute("code", commonAttributes.code());
517        attrs.addCDATAAttribute("name", commonAttributes.name());
518        boolean excludedFromSkills = commonAttributes.excludedFromSkills();
519        if (excludedFromSkills)
520        {
521            attrs.addCDATAAttribute("excludedFromSkills", String.valueOf(excludedFromSkills));
522        }
523        
524        if (ancestorPath != null)
525        {
526            attrs.addCDATAAttribute("path", EducationalPath.of(ancestorPath.toArray(ProgramItem[]::new)).toString());
527        }
528    }
529    
530    private record CommonAttributes(String id, String title, String code, String name, boolean excludedFromSkills) { }
531    
532    /**
533     * SAX the item of a reference table
534     * @param itemId the item id
535     * @param tagName the tag name
536     * @param lang the language to use
537     * @throws SAXException if an error occurs while saxing
538     */
539    protected void _saxReferenceTableItem(String itemId, String tagName, String lang) throws SAXException
540    {
541        Triple<String, String, String> cacheKey = Triple.of(itemId, tagName, lang);
542        SaxBuffer buffer = _refItemCache.get(cacheKey);
543        
544        if (buffer != null)
545        {
546            buffer.toSAX(contentHandler);
547            return;
548        }
549        
550        buffer = new SaxBuffer();
551        
552        OdfReferenceTableEntry item = Optional.ofNullable(itemId)
553                                              .filter(StringUtils::isNotEmpty)
554                                              .map(_odfReferenceTableHelper::getItem)
555                                              .orElse(null);
556        
557        if (item != null)
558        {
559            AttributesImpl attrs = new AttributesImpl();
560            attrs.addCDATAAttribute("id", item.getId());
561            _addAttrIfNotEmpty(attrs, "code", item.getCode());
562            
563            XMLUtils.createElement(buffer, tagName, attrs, item.getLabel(lang));
564            
565            _refItemCache.put(cacheKey, buffer);
566            buffer.toSAX(contentHandler);
567        }
568    }
569    
570    /**
571     * Add an attribute if its not null or empty.
572     * @param attrs The attributes
573     * @param attrName The attribute name
574     * @param attrValue The attribute value
575     */
576    protected void _addAttrIfNotEmpty(AttributesImpl attrs, String attrName, String attrValue)
577    {
578        if (StringUtils.isNotEmpty(attrValue))
579        {
580            attrs.addCDATAAttribute(attrName, attrValue);
581        }
582    }
583
584}