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}