001/*
002 *  Copyright 2020 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.skin;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.nio.file.Files;
021import java.nio.file.Path;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.Date;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Set;
031import java.util.stream.Collectors;
032
033import org.apache.avalon.framework.configuration.Configuration;
034import org.apache.avalon.framework.configuration.ConfigurationException;
035import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.excalibur.source.Source;
038import org.apache.excalibur.source.SourceException;
039import org.apache.excalibur.source.TraversableSource;
040import org.slf4j.Logger;
041import org.slf4j.LoggerFactory;
042
043import org.ametys.core.util.LambdaUtils;
044import org.ametys.core.util.LambdaUtils.LambdaException;
045import org.ametys.runtime.i18n.I18nizableText;
046
047/**
048 * A skin
049 */
050public class Skin
051{
052    static final String CONF_PATH = "conf/skin.xml";
053    static final String TEMPLATES_PATH = "templates";
054    
055    private static Logger _logger = LoggerFactory.getLogger(Skin.class);
056    
057    /** The skin id (e.g. the directory name) */
058    protected String _id;
059    /** The skin root path */
060    protected Path _path; 
061    /** The skin name */
062    protected I18nizableText _label;
063    /** The skin description */
064    protected I18nizableText _description;
065    /** The skin thumbnail 16x16 */
066    protected String _smallImage;
067    /** The skin thumbnail 32x32 */
068    protected String _mediumImage;
069    /** The skin thumbnail 48x48 */
070    protected String _largeImage;
071    /** The map of templates id and associated templates */
072    protected Map<String, SkinTemplate> _templates = new HashMap<>();
073    
074    /** The last time the file was loaded */
075    protected long _lastConfUpdate;
076    /** Is the skin modifiable */
077    protected boolean _modifiable;
078    /** Is the skin abstract */
079    protected boolean _abstract;
080    /** Parents of the skin in the inheritance process */
081    protected List<String> _parents = Collections.EMPTY_LIST;
082    
083    /** The skins manager to access components */
084    protected SkinsManager _skinsManager;
085    /** The skin configuration helper */
086    protected SkinConfigurationHelper _skinConfigurationHelper;
087    
088    /**
089     * Creates a skin
090     * @param id The id of the skin (e.g. the directory name)
091     * @param skinPath The skin root path
092     * @param modifiable Is this skin modifiable?
093     * @param skinsManager The skins manager
094     * @param skinConfigurationHelper The skin configuration helper
095     */
096    public Skin(String id, Path skinPath, boolean modifiable, SkinsManager skinsManager, SkinConfigurationHelper skinConfigurationHelper)
097    {
098        _id = id;
099        _path = skinPath;
100        _modifiable = modifiable;
101        
102        _skinsManager = skinsManager;
103        _skinConfigurationHelper = skinConfigurationHelper;
104    }
105    
106    /** 
107     * Dispose skin component 
108     */
109    public void dispose()
110    {
111        for (SkinTemplate template : _templates.values())
112        {
113            template.dispose();
114        }
115    }
116    
117    /**
118     * Is the skin modifiable?
119     * @return true if modifiable
120     */
121    public boolean isModifiable()
122    {
123        return _modifiable;
124    }
125    
126    /**
127     * Is the skin abstract?
128     * @return true if abstract
129     */
130    public boolean isAbstract()
131    {
132        return _abstract;
133    }
134    
135    /**
136     * Is the skin modifiable?
137     * @return true if modifiable
138     */
139    public boolean isConfigurable()
140    {
141        // Does not support inheritance... fine for now
142        return isModifiable() && Files.exists(_path.resolve("stylesheets/config/config-model.xml"));
143    }
144    
145    /**
146     * Get the parent skins from the inheritance point of view.
147     * Consider using {@link SkinsManager#getSkinAndParents}
148     * @return The non null list of parents. The sooner a parent appears in the list, the stronger it is.
149     */
150    public List<String> getParents()
151    {
152        return _parents;
153    }
154    
155    /**
156     * Get the list of existing templates
157     * @return A set of skin names. Can be null if there is an error.
158     */
159    public Set<String> getTemplates()
160    {
161        TraversableSource templatesSource = null;
162        try
163        {
164            templatesSource = (TraversableSource) _skinsManager.getSourceResolver().resolveURI("skin:" + _id + "://" + TEMPLATES_PATH);
165            
166            Collection<TraversableSource> children = templatesSource.getChildren();
167            return children.stream()
168                .filter(LambdaUtils.wrapPredicate(this::_isATemplate))
169                .map(s -> s.getName())
170                .collect(Collectors.toSet());
171        }
172        catch (LambdaException | IOException e)
173        {
174            _logger.error("Can not determine the list of templates available for skin '" + _id + "'", e);
175            return null;
176        }
177        finally
178        {
179            _skinsManager.getSourceResolver().release(templatesSource);
180        }
181    }
182    
183    /**
184     * Get a template
185     * @param id The id of the template
186     * @return The template or null if the template doesn't exists
187     */
188    public SkinTemplate getTemplate(String id)
189    {
190        TraversableSource templateSource = null;
191        try
192        {
193            templateSource = (TraversableSource) _skinsManager.getSourceResolver().resolveURI("skin:" + _id + "://" + TEMPLATES_PATH + "/" + id);
194            if (_isATemplate(templateSource))
195            {
196                SkinTemplate template = _templates.get(id);
197                if (template == null)
198                {
199                    template = new SkinTemplate(this._id, id, _skinsManager, _skinConfigurationHelper);
200                    _templates.put(id, template);
201                }
202                template.refreshValues();
203                return template;
204            }
205            else
206            {
207                if (_templates.containsKey(id))
208                {
209                    SkinTemplate skinTemplate = _templates.get(id);
210                    skinTemplate.dispose();
211                    _templates.remove(id);
212                }
213                return null;
214            }
215        }
216        catch (IOException e)
217        {
218            throw new IllegalStateException("Can not get the template '" + id + "' for skin '" + this._id + "'", e);
219        }
220        finally 
221        {
222            _skinsManager.getSourceResolver().release(templateSource);
223        }
224    }
225    
226    private boolean _isATemplate(TraversableSource path) throws SourceException
227    {
228        if (path.exists() && path.isCollection())
229        {
230            TraversableSource stylesheets = (TraversableSource) path.getChild("stylesheets");
231            if (stylesheets.exists() && stylesheets.isCollection())
232            {
233                Source templateXsl = stylesheets.getChild("template.xsl");
234                return templateXsl.exists();
235            }
236        }
237        return false;
238    }
239
240    /**
241     * The configuration default values (if configuration file does not exist or is unreadable)
242     */
243    protected void _defaultValues()
244    {
245        _lastConfUpdate = new Date().getTime();
246        
247        this._abstract = false;
248        this._label = new I18nizableText(this._id);
249        this._description = new I18nizableText("");
250        this._smallImage = "/plugins/web/resources/img/skin/skin_16.png";
251        this._mediumImage = "/plugins/web/resources/img/skin/skin_32.png";
252        this._largeImage = "/plugins/web/resources/img/skin/skin_48.png";
253    }
254    
255    /**
256     * Refresh the configuration values
257     */
258    public void refreshValues()
259    {
260        try
261        {
262            Path configurationPath = _path.resolve(CONF_PATH); // No inheritance here... normal since this is the file that tell us about inheritance
263            if (Files.exists(configurationPath))
264            {
265                long fileTime = Files.getLastModifiedTime(configurationPath).toMillis();
266                if (_lastConfUpdate < fileTime)
267                {
268                    _lastConfUpdate = fileTime;
269                    try (InputStream is = Files.newInputStream(configurationPath))
270                    {
271                        Configuration configuration = new DefaultConfigurationBuilder().build(is);
272                        
273                        this._abstract = configuration.getAttributeAsBoolean("abstract", false);
274                        this._parents = _parseParents(configuration.getAttribute("extends", ""));
275                        this._label = _configureI18n(configuration.getChild("label", false), new I18nizableText(this._id));
276                        this._description = _configureI18n(configuration.getChild("description", false), new I18nizableText(""));
277                        this._smallImage = _configureThumbnail(configuration.getChild("thumbnail").getChild("small").getValue(null), "/plugins/web/resources/img/skin/skin_16.png");
278                        this._mediumImage = _configureThumbnail(configuration.getChild("thumbnail").getChild("medium").getValue(null), "/plugins/web/resources/img/skin/skin_32.png");
279                        this._largeImage = _configureThumbnail(configuration.getChild("thumbnail").getChild("large").getValue(null), "/plugins/web/resources/img/skin/skin_48.png");
280                    }
281                }
282            }
283            else
284            {
285                _defaultValues();
286            }
287        }
288        catch (Exception e)
289        {
290            _defaultValues();
291            if (_logger.isWarnEnabled())
292            {
293                _logger.warn("Cannot read the configuration file " + CONF_PATH + " for the skin '" + this._id + "'. Continue as if file was not existing", e);
294            }
295        }
296    }
297    
298    private List<String> _parseParents(String parents)
299    {
300        return Arrays.asList(StringUtils.split(parents, ',')).stream()
301            .map(StringUtils::trimToNull)
302            .filter(Objects::nonNull)
303            .collect(Collectors.toList());
304    }
305
306    private String _configureThumbnail(String value, String defaultImage)
307    {
308        if (value == null)
309        {
310            return defaultImage;
311        }
312        else
313        {
314            return "/skins/" + this._id + "/resources/" + value;
315        }
316    }
317
318    private I18nizableText _configureI18n(Configuration child, I18nizableText defaultValue) throws ConfigurationException
319    {
320        if (child != null)
321        {
322            String value = child.getValue();
323            if (child.getAttributeAsBoolean("i18n", false))
324            {
325                return new I18nizableText("skin." + this._id, value);
326            }
327            else
328            {
329                return new I18nizableText(value);
330            }
331        }
332        else
333        {
334            return defaultValue;
335        }
336    }
337
338    /**
339     * The skin id
340     * @return the id
341     */
342    public String getId()
343    {
344        return _id;
345    }
346    
347    /**
348     * The skin label
349     * @return The label
350     */
351    public I18nizableText getLabel()
352    {
353        return _label;
354    }
355    /**
356     * The skin description
357     * @return The description. Can not be null but can be empty
358     */
359    public I18nizableText getDescription()
360    {
361        return _description;
362    }
363    
364    /**
365     * The small image file uri
366     * @return The small image file uri
367     */
368    public String getSmallImage()
369    {
370        return _smallImage;
371    }
372
373    /**
374     * The medium image file uri
375     * @return The medium image file uri
376     */
377    public String getMediumImage()
378    {
379        return _mediumImage;
380    }
381    
382    /**
383     * The large image file uri
384     * @return The large image file uri
385     */
386    public String getLargeImage()
387    {
388        return _largeImage;
389    }
390
391    /**
392     * Get the skin's path. Should not be used. Use the skin:// protocol.
393     * @return the skin's path
394     */
395    public Path getRawPath ()
396    {
397        return _path;
398    }
399}