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