001/*
002 *  Copyright 2026 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.net.MalformedURLException;
021import java.util.ArrayList;
022import java.util.HashMap;
023import java.util.LinkedHashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.Set;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029
030import org.apache.avalon.framework.activity.Initializable;
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.configuration.Configuration;
033import org.apache.avalon.framework.configuration.ConfigurationException;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.excalibur.source.Source;
039import org.apache.excalibur.source.SourceResolver;
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.core.ui.Callable;
047import org.ametys.core.util.filereloader.FileReloader;
048import org.ametys.core.util.filereloader.FileReloaderUtils;
049import org.ametys.plugins.repository.AmetysObjectResolver;
050import org.ametys.runtime.i18n.I18nizableText;
051import org.ametys.runtime.plugin.component.AbstractLogEnabled;
052
053/**
054 * Component to get the crop image previews brought by the skins
055 */
056public class ImageCropPreviewsComponent extends AbstractLogEnabled implements Component, Initializable, Serviceable, FileReloader
057{
058    private static final Pattern __SKIN_SOURCE_PATTERN = Pattern.compile("^skin:(.*)://(.*)$");
059    private static final String __PREVIEWS_CACHE = ImageCropPreviewsComponent.class.getName() + "$previewsCache";
060    
061    /** The path to the configuration file */
062    private static final String __CONF_FILE_PATH = "conf/image-crop-previews.xml";
063    
064    /** The skins manager */
065    protected SkinsManager _skinsManager;
066    /** The source resolver */
067    protected SourceResolver _srcResolver;
068
069    private AbstractCacheManager _cacheManager;
070    private FileReloaderUtils _fileReloader;
071    private ContentTypesHelper _cTypesHelper;
072    private AmetysObjectResolver _resolver;
073    private SkinConfigurationHelper _skinConfigurationHelper;
074
075    @Override
076    public void service(ServiceManager smanager) throws ServiceException
077    {
078        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
079        _srcResolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE);
080        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
081        _fileReloader = (FileReloaderUtils) smanager.lookup(FileReloaderUtils.ROLE);
082        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
083        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
084        _skinConfigurationHelper = (SkinConfigurationHelper) smanager.lookup(SkinConfigurationHelper.ROLE);
085    }
086    
087    @Override
088    public void initialize() throws Exception
089    {
090        _createCaches();
091    }
092
093    /**
094     * Creates the caches
095     */
096    protected void _createCaches()
097    {
098        _cacheManager.createMemoryCache(__PREVIEWS_CACHE, 
099                new I18nizableText("plugin.web", "PLUGINS_WEB_SKIN_SITEMAP_DECORATORS_CACHE_LABEL"),
100                new I18nizableText("plugin.web", "PLUGINS_WEB_SKIN_SITEMAP_DECORATORS_CACHE_DESCRIPTION"),
101                true,
102                null);
103    }
104    
105    /**
106     * Get the image crop previews defined by the current skin for a given content
107     * @param contentId the content id
108     * @return the image crop previews as json
109     */
110    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
111    public List<Map<String, Object>> getCropPreviewsForContent(String contentId)
112    {
113        String skinName = _skinsManager.getSkinNameFromRequest();
114        if (StringUtils.isEmpty(skinName))
115        {
116            return List.of();
117        }
118        
119        Content content = _resolver.resolveById(contentId);
120        
121        return getCropPreviews(skinName)
122                .stream()
123                .filter(cp -> {
124                    Set<String> excludedContentTypes = cp.excludedContentTypes();
125                    Set<String> includedContentTypes = cp.includedContentTypes();
126                    if (excludedContentTypes != null && excludedContentTypes.size() > 0)
127                    {
128                        for (String cTypeId : excludedContentTypes)
129                        {
130                            if (_cTypesHelper.isInstanceOf(content, cTypeId))
131                            {
132                                return false;
133                            }
134                        }
135                        return true;
136                    }
137                    else if (includedContentTypes != null && includedContentTypes.size() > 0)
138                    {
139                        for (String cTypeId : includedContentTypes)
140                        {
141                            if (_cTypesHelper.isInstanceOf(content, cTypeId))
142                            {
143                                return true;
144                            }
145                        }
146                        return false;
147                    }
148                    return true;
149                })
150                .map(cp -> cp.toJson())
151                .toList();
152    }
153    
154    
155    /**
156     * Get the list of {@link ImageCropPreview} defined by the skin
157     * @param skinName The skin name
158     * @return The list of {@link ImageCropPreview}
159     */
160    public List<ImageCropPreview> getCropPreviews(String skinName)
161    {
162        try
163        {
164            boolean force = !_getPreviewsCache().hasKey(skinName);
165            _fileReloader.updateFile("skin:" + skinName + "://conf/image-crop-previews.xml", force, this);
166        }
167        catch (Exception e)
168        {
169            getLogger().error("Unable to read the available image crop preview configuration file for skin '{}'", skinName, e);
170        }
171
172        List<ImageCropPreview> previews = _getPreviewsCache().get(skinName);
173        return previews != null ? previews : List.of();
174    }
175    
176    private Configuration _getSkinCropPreviewsConfiguration(String skinName) throws IOException, ConfigurationException, SAXException
177    {
178        Skin skin = _skinsManager.getSkin(skinName);
179        try (InputStream xslIs = getClass().getResourceAsStream("image-crop-previews-merge.xsl"))
180        {
181            return _skinConfigurationHelper.getInheritanceMergedConfiguration(skin, __CONF_FILE_PATH, xslIs);
182        }
183    }
184
185    public void updateFile(String sourceUrl, Source source, InputStream is) throws Exception
186    {
187        Matcher m = __SKIN_SOURCE_PATTERN.matcher(sourceUrl);
188        if (!m.matches())
189        {
190            throw new MalformedURLException("URI must be like skin:<name>://path/to/resource. Location was '" + sourceUrl + "'");
191        }
192        
193        String skinId = m.group(1);
194        List<ImageCropPreview> previews = new ArrayList<>();
195        
196        if (is != null)
197        {
198            Configuration configuration = _getSkinCropPreviewsConfiguration(skinId);
199            for (Configuration cropConfiguration : configuration.getChildren("preview"))
200            {
201                int width = cropConfiguration.getAttributeAsInteger("width");
202                int height = cropConfiguration.getAttributeAsInteger("height");
203                
204                I18nizableText label = null;
205                Configuration labelConf = cropConfiguration.getChild("label", false);
206                if (labelConf != null)
207                {
208                    label = I18nizableText.parseI18nizableText(labelConf, "skin." + skinId);
209                }
210                
211                Set<String> includedContentTypes = null;
212                Set<String> excludedContentTypes = null;
213                
214                Configuration cTypeConf = cropConfiguration.getChild("content-types", false);
215                if (cTypeConf != null)
216                {
217                    String mode = cTypeConf.getAttribute("mode", "include");
218                    
219                    if (!"include".equals(mode) && !"exclude".equals(mode))
220                    {
221                        throw new IllegalArgumentException("Invalid content-types mode '" + mode + "' for crop preview in skin '" + skinId + "'. Must be 'include' or 'exclude'.");
222                    }
223                    
224                    Set<String> contentTypes = new LinkedHashSet<>();
225                    for (Configuration contentTypeConf : cTypeConf.getChildren("content-type"))
226                    {
227                        contentTypes.add(contentTypeConf.getAttribute("id"));
228                    }
229                    
230                    if (mode.equals("include"))
231                    {
232                        includedContentTypes = contentTypes;
233                    }
234                    else if (mode.equals("exclude"))
235                    {
236                        excludedContentTypes = contentTypes;
237                    }
238                    
239                }
240                
241                previews.add(new ImageCropPreview(width, height, label, includedContentTypes, excludedContentTypes));
242            }
243        }
244        
245        // Fill cache
246        Cache<String, List<ImageCropPreview>> previewsCache = _getPreviewsCache();
247        previewsCache.put(skinId, previews);
248    }
249    
250    public String getId(String sourceUrl)
251    {
252        return sourceUrl;
253    }
254    
255    private Cache<String, List<ImageCropPreview>> _getPreviewsCache()
256    {
257        return _cacheManager.get(__PREVIEWS_CACHE);
258    }
259
260    /**
261     * Represents a preview for an image crop, with its dimensions, label and content types conditions
262     * @param width the crop width
263     * @param height the crop height
264     * @param label the crop label. Can be null.
265     * @param includedContentTypes the content types for which this crop preview should be included. If null, it is included for all content types
266     * @param excludedContentTypes the content types for which this crop preview should be excluded. If null, it is not excluded for any content type
267     */
268    public record ImageCropPreview(int width, int height, I18nizableText label, Set<String> includedContentTypes, Set<String> excludedContentTypes) { 
269        /**
270         * Returns the json representation of a crop image preview
271         * @return the json representation
272         */
273        public Map<String, Object> toJson()
274        {
275            Map<String, Object> json = new HashMap<>();
276            json.put("width", this.width);
277            json.put("height", this.height);
278            
279            if (this.label != null)
280            {
281                json.put("label", this.label);
282            }
283            
284            if (this.includedContentTypes != null)
285            {
286                json.put("includedContentTypes", this.includedContentTypes);
287            }
288            if (this.excludedContentTypes != null)
289            {
290                json.put("excludedContentTypes", this.excludedContentTypes);
291            }
292            
293            return json;
294        }
295    }
296}