001/*
002 *  Copyright 2022 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.web.contenttype;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.Collection;
021import java.util.HashSet;
022import java.util.Objects;
023import java.util.Set;
024import java.util.regex.Matcher;
025import java.util.regex.Pattern;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.activity.Initializable;
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
033import org.apache.avalon.framework.context.Context;
034import org.apache.avalon.framework.context.ContextException;
035import org.apache.avalon.framework.context.Contextualizable;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.avalon.framework.service.Serviceable;
039import org.apache.avalon.framework.thread.ThreadSafe;
040import org.apache.cocoon.components.ContextHelper;
041import org.apache.cocoon.environment.Request;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.excalibur.source.SourceResolver;
044import org.apache.excalibur.source.TraversableSource;
045import org.xml.sax.SAXException;
046
047import org.ametys.cms.contenttype.ContentType;
048import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
049import org.ametys.cms.repository.Content;
050import org.ametys.core.DevMode;
051import org.ametys.core.DevMode.DEVMODE;
052import org.ametys.core.cache.AbstractCacheManager;
053import org.ametys.core.cache.Cache;
054import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
055import org.ametys.runtime.i18n.I18nizableText;
056import org.ametys.runtime.plugin.component.AbstractLogEnabled;
057import org.ametys.web.WebConstants;
058import org.ametys.web.skin.SkinsManager;
059
060/**
061 * Helper to get content type's views brought by the skin, linked to a model view
062 * 
063 */
064public class SkinContentViewHelper extends AbstractLogEnabled implements Component, Serviceable, ThreadSafe, Initializable, Contextualizable
065{
066    /** The avalon role */
067    public static final String ROLE = SkinContentViewHelper.class.getName();
068    
069    /** The name of request parameter for skin content view */
070    public static final String REQUEST_PARAM_RENDERING_VIEW_NAME = "renderingViewName";
071    
072    /** Constant for the {@link Cache} id for the pages in cache by sitename, lang, tag */
073    private static final String __SKIN_CONTENT_VIEW_CACHE = SkinContentViewHelper.class.getName() + "$SkinContentView";
074    
075    private SourceResolver _srcResolver;
076    private SkinsManager _skinsManager;
077    private ContentTypeExtensionPoint _cTypeEP;
078
079    private AbstractCacheManager _cacheManager;
080
081    private Context _context;
082
083    public void service(ServiceManager smanager) throws ServiceException
084    {
085        _srcResolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE);
086        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
087        _cTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
088        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
089    }
090    
091    public void initialize() throws Exception
092    {
093        _createCaches();
094    }
095    
096    public void contextualize(Context context) throws ContextException
097    {
098        _context = context;
099        
100    }
101    
102    private void _createCaches()
103    {
104        DEVMODE devMode = DevMode.getDeveloperMode(null);
105        
106        switch (devMode)
107        {
108            case DEVELOPMENT:
109            case SUPER_DEVELOPPMENT:
110                _cacheManager.createRequestCache(__SKIN_CONTENT_VIEW_CACHE, 
111                    new I18nizableText("plugin.web", "PLUGINS_WEB_SKINCONTENTVIEW_CACHE_LABEL"),
112                    new I18nizableText("plugin.web", "PLUGINS_WEB_SKINCONTENTVIEW_CACHE_DESCRIPTION"),
113                    true);
114                break;
115            default:
116                _cacheManager.createMemoryCache(__SKIN_CONTENT_VIEW_CACHE, 
117                        new I18nizableText("plugin.web", "PLUGINS_WEB_SKINCONTENTVIEW_CACHE_LABEL"),
118                        new I18nizableText("plugin.web", "PLUGINS_WEB_SKINCONTENTVIEW_CACHE_DESCRIPTION"),
119                        true,
120                        null);
121                break;
122        }
123    }
124    
125    /**
126     * Rendering view of a content brought by a skin
127     * @param skinName the skin name
128     * @param name the view name
129     * @param modelViewName the model view used by this rendering view
130     * @param format the output format (html, pdf, doc, ..)
131     * @param label the view label
132     * @param description the view description
133     * @param iconGlyph the icon glyph. Can be null.
134     * @param iconDecorator the icon decoration. Can be null.
135     * @param iconSmall the small icon. Can be null.
136     * @param iconMedium the medium icon. Can be null.
137     * @param iconLarge the large icon. Can be null.
138     */
139    public record SkinContentView(String skinName, String name, String modelViewName, String format, I18nizableText label, I18nizableText description, String iconGlyph, String iconDecorator, String iconSmall, String iconMedium, String iconLarge) { /* empty */ }
140
141    
142    /**
143     * Get the HTML content view brought by the current skin for the given content
144     * @param name the name of the view
145     * @param content the content
146     * @return the skin content view or null if view does not exists in skin
147     */
148    public SkinContentView getContentViewFromSkin(String name, Content content)
149    {
150        return getContentViewFromSkin(name, content, "html");
151    }
152    
153    /**
154     * Get the content view brought by the current skin for the given content
155     * @param name the name of the view
156     * @param content the content
157     * @param format the output format (html, pdf, doc, ...)
158     * @return the skin content view or null if view does not exists in skin
159     */
160    public SkinContentView getContentViewFromSkin(String name, Content content, String format)
161    {
162        for (String contentTypeId : content.getTypes())
163        {
164            SkinContentView view = getContentViewFromSkin(name, contentTypeId, format);
165            if (view != null)
166            {
167                return view;
168            }
169        }
170        
171        return null;
172    }
173    
174    /**
175     * Get the HTML content view brought by the current skin for a given content type
176     * @param name the name of the view
177     * @param contentTypeId the content type id
178     * @return the skin content view or null if view does not exists in skin
179     */
180    public SkinContentView getContentViewFromSkin(String name, String contentTypeId)
181    {
182        return getContentViewFromSkin(name, contentTypeId, "html");
183    }
184   
185    /**
186     * Get the content view brought by the current skin for a given content type
187     * @param name the name of the view
188     * @param contentTypeId the content type id
189     * @param format the output format (html, pdf, doc, ...)
190     * @return the skin content view or null if view does not exists in skin
191     */
192    public SkinContentView getContentViewFromSkin(String name, String contentTypeId, String format)
193    {
194        return getContentViewsFromSkin(contentTypeId, format)
195                .stream()
196                .filter(v -> name.equals(v.name()))
197                .findFirst()
198                .orElse(null);
199    }
200    
201    /**
202     * Get the HTML content views brought by the current skin for a given content type
203     * @param contentTypeId the content type id
204     * @return the skin content view
205     */
206    public Set<SkinContentView> getContentViewsFromSkin(String contentTypeId)
207    {
208        return getContentViewsFromSkin(contentTypeId, "html");
209    }
210    
211    /**
212     * Get all the content views brought by the current skin for a given content type
213     * @param contentTypeId the content type id
214     * @param format the output format (html, pdf, doc, ...)
215     * @return the skin content view
216     */
217    public Set<SkinContentView> getContentViewsFromSkin(String contentTypeId, String format)
218    {
219        String skinName = _skinsManager.getSkinNameFromRequest();
220        ContentType contentType = _cTypeEP.getExtension(contentTypeId);
221        if (contentType == null)
222        {
223            throw new IllegalStateException("Cannot get the content views for the unexisting content type '" + contentTypeId + "'");
224        }
225        return getContentViewsFromSkin(skinName, contentType, format);
226    }
227    
228    /**
229     * Get the HTML content views brought by the current skin for a given content type
230     * @param contentType the content type
231     * @return the skin content view
232     */
233    public Set<SkinContentView> getContentViewsFromSkin(ContentType contentType)
234    {
235        String skinName = _skinsManager.getSkinNameFromRequest();
236        return getContentViewsFromSkin(skinName, contentType);
237    }
238    
239    /**
240     * Get the HTML content views brought by a given skin for a given content type
241     * @param skinName the skin name
242     * @param contentType the content type
243     * @return the skin content view
244     */
245    public Set<SkinContentView> getContentViewsFromSkin(String skinName, ContentType contentType)
246    {
247        return getContentViewsFromSkin(skinName, contentType, "html");
248    }
249    
250    /**
251     * Get the content views brought by the a skin for a given content type and output format
252     * @param skinName the skin name
253     * @param contentType the content type
254     * @param format the output format (html, pdf, doc, ...)
255     * @return the skin content view
256     */
257    public Set<SkinContentView> getContentViewsFromSkin(String skinName, ContentType contentType, String format)
258    {
259        return _getCache().get(SkinContentViewKey.of(skinName, contentType.getId(), format), __ -> _computeContentViewsFromSkin(skinName, contentType, format));
260    }
261    
262    private Set<SkinContentView> _computeContentViewsFromSkin(String skinName, ContentType contentType, String format)
263    {    
264        Set<SkinContentView> skinViews = new HashSet<>();
265        
266        String contentTypeAlias = StringUtils.substringAfterLast(contentType.getId(), ".");
267        if (StringUtils.isNotEmpty(contentTypeAlias))
268        {
269            Request request = ContextHelper.getRequest(_context);
270            String currentSkinName = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SKIN_ID);
271            
272            TraversableSource source = null;
273            try
274            {
275                request.setAttribute(WebConstants.REQUEST_ATTR_SKIN_ID, skinName);
276                
277                source = (TraversableSource) _srcResolver.resolveURI("skin://stylesheets/content/" + contentTypeAlias);
278                if (source.exists() && source.isCollection())
279                {
280                    Collection<TraversableSource> children = source.getChildren();
281                    return children.stream()
282                        .filter(f -> !f.isCollection())
283                        .filter(f -> f.getName().endsWith(".xml"))
284                        .map(f -> _parseSkinContentTypeView(skinName, f, contentType, contentTypeAlias))
285                        .filter(Objects::nonNull)
286                        .filter(v -> format.equals(v.format()))
287                        .collect(Collectors.toSet());
288                }
289            }
290            catch (IOException e)
291            {
292                getLogger().warn("Unable to list content type's view brought by current skin for content type '" + contentType.getId() + "'", e);
293            }
294            finally
295            {
296                request.setAttribute(WebConstants.REQUEST_ATTR_SKIN_ID, currentSkinName);
297                _srcResolver.release(source);
298            }
299        }
300        
301        return skinViews;
302    }
303    
304    private SkinContentView _parseSkinContentTypeView(String skinName, TraversableSource xmlSource, ContentType contentType, String contentTypeAlias)
305    {
306        String defaultCatalog = "skin." + skinName;
307        
308        // File name is such as news-carousel.xml, article2pdf-other.xml, ...
309        Pattern viewPattern = Pattern.compile("^" + contentTypeAlias + "(?:2([^-]+))?-([^.]+)\\.xml$");
310        
311        Matcher m = viewPattern.matcher(xmlSource.getName());
312        if (!m.matches())
313        {
314            getLogger().info("The file at uri '{}' does not match a skin content view. It will be ignored.", xmlSource.getURI());
315            return null;
316        }
317        
318        try (InputStream inputStream = xmlSource.getInputStream())
319        {
320            String format = m.group(1);
321            String viewName = m.group(2);
322            
323            if (contentType.getView(viewName) != null)
324            {
325                getLogger().warn("The content view defined by skin '{}' for content type '{}', is named as an already existing model view '{}' at uri {}. This view is ignored.", skinName, contentType.getId(), viewName, xmlSource.getURI());
326                return null;
327            }
328            
329            Configuration conf = new DefaultConfigurationBuilder().build(inputStream);
330            
331            String modelViewName = conf.getChild("view").getValue(null);
332            
333            if (StringUtils.isEmpty(modelViewName))
334            {
335                getLogger().warn("Missing <view> configuration for content view defined by skin '{}' for content type '{}' at uri {}. This view is ignored.", skinName, contentType.getId(), xmlSource.getURI());
336                return null;
337                // throw new IllegalArgumentException("Missing <view> configuration for content view defined by skin '" + skinName + "' at uri " + xmlSource.getURI());
338            }
339            
340            if (contentType.getView(modelViewName) == null)
341            {
342                getLogger().warn("The content view defined by skin '{}' for content type '{}' refers a non-existing model view '{}' at uri {}. This view is ignored.", skinName, contentType.getId(), modelViewName, xmlSource.getURI());
343                return null;
344                //throw new IllegalArgumentException("The content view defined by skin '" + skinName + "' for content type '" + contentType.getId() + "' refers a non-existing model view '" + modelViewName + "' at uri " + xmlSource.getURI());
345            }
346            
347            I18nizableText label = null;
348            Configuration labelConf = conf.getChild("label", false);
349            if (labelConf != null)
350            {
351                label = I18nizableText.parseI18nizableText(labelConf, defaultCatalog);
352            }
353            else
354            {
355                // No label defined
356                label = new I18nizableText(viewName);
357            }
358            
359            I18nizableText description = null;
360            Configuration descConf = conf.getChild("description", false);
361            if (descConf != null)
362            {
363                description = I18nizableText.parseI18nizableText(descConf, defaultCatalog);
364            }
365            
366            Configuration iconsConfig = conf.getChild("icons");
367            String iconGlyph = iconsConfig.getChild("glyph").getValue(null);
368            String iconDecorator = iconsConfig.getChild("decorator").getValue(null);
369            String smallIcon = _parseIcon(iconsConfig, "small", skinName);
370            String mediumcon = _parseIcon(iconsConfig, "medium", skinName);
371            String largeIcon = _parseIcon(iconsConfig, "large", skinName);
372            
373            return new SkinContentView(skinName, viewName, modelViewName, format != null ? format : "html", label, description, iconGlyph, iconDecorator, smallIcon, mediumcon, largeIcon);
374        }
375        catch (IOException | ConfigurationException | SAXException e)
376        {
377            getLogger().warn("Unable to parse skin content view '{}'. This view is ignored.", xmlSource.getURI(), e);
378            return null;
379        }
380    }
381    
382    private String _parseIcon(Configuration iconsConfig, String name, String skinName) throws ConfigurationException
383    {
384        Configuration iconConfig = iconsConfig.getChild(name, false);
385        if (iconConfig != null)
386        {
387            return "/skins/" + skinName + "/resources/" + iconConfig.getValue();
388        }
389        
390        return null;
391    }
392    
393    static class SkinContentViewKey extends AbstractCacheKey
394    {
395        SkinContentViewKey(String skinId, String contentTypeId, String format)
396        {
397            super(skinId, contentTypeId, format);
398        }
399        static SkinContentViewKey of(String skinId, String contentTypeId, String format)
400        {
401            return new SkinContentViewKey(skinId, contentTypeId, format);
402        }
403    }
404    
405    private Cache<SkinContentViewKey, Set<SkinContentView>> _getCache()
406    {
407        return _cacheManager.get(__SKIN_CONTENT_VIEW_CACHE);
408    }
409
410}