001/*
002 *  Copyright 2025 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.content;
017
018import java.io.InputStream;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Optional;
025import java.util.Set;
026
027import org.apache.avalon.framework.activity.Initializable;
028import org.apache.avalon.framework.configuration.Configuration;
029import org.apache.avalon.framework.configuration.ConfigurationException;
030import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Request;
038import org.apache.commons.lang3.ArrayUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.commons.lang3.tuple.ImmutablePair;
041import org.apache.commons.lang3.tuple.Pair;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.source.SourceResolver;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046
047import org.ametys.cms.content.ContentViewProvider;
048import org.ametys.cms.content.DefaultContentViewProvider;
049import org.ametys.cms.contenttype.ContentType;
050import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
051import org.ametys.cms.contenttype.ContentTypesParserHelper;
052import org.ametys.cms.contenttype.ContentTypesParserHelper.ViewConfigurations;
053import org.ametys.cms.contenttype.ContentTypesParserHelper.ViewConfigurationsByType;
054import org.ametys.core.cache.AbstractCacheManager;
055import org.ametys.core.cache.Cache;
056import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.model.ItemParserHelper.ConfigurationAndPluginName;
059import org.ametys.runtime.model.View;
060import org.ametys.runtime.model.ViewParserContext;
061import org.ametys.web.WebHelper;
062import org.ametys.web.contenttype.SkinContentTypeOverridesExtensionPoint;
063import org.ametys.web.contenttype.WebViewParserContext;
064import org.ametys.web.repository.site.Site;
065import org.ametys.web.repository.site.SiteManager;
066import org.ametys.web.skin.Skin;
067import org.ametys.web.skin.SkinConfigurationHelper;
068import org.ametys.web.skin.SkinsManager;
069
070/**
071 * This implementation of a {@link ContentViewProvider} looks first if the view has been defined (or overridden) in the current skin
072 */
073public class WebContentViewProvider extends DefaultContentViewProvider implements Contextualizable, Initializable
074{
075    /** The logger. */
076    protected static final Logger _LOGGER = LoggerFactory.getLogger(WebContentViewProvider.class);
077    
078    private static final String __VIEW_CACHE = WebContentViewProvider.class.getName() + "$view.cache";
079    
080    /** The skin manager */
081    protected SkinsManager _skinsManager;
082    /** The content type extension point */
083    protected ContentTypeExtensionPoint _contentTypeExtensionPoint;
084    /** The content types parser helper */
085    protected ContentTypesParserHelper _contentTypesParserHelper;
086    /** The source resolver */
087    protected SourceResolver _srcResolver;
088    /** The skin configuration helper */
089    protected SkinConfigurationHelper _skinConfigurationHelper;
090    /** The extension point with overrides specific to skins */
091    protected SkinContentTypeOverridesExtensionPoint _skinContentTypeOverridesExtensionPoint;
092    /** The site manager */
093    protected SiteManager _siteManager;
094    /** The cache manager */
095    protected AbstractCacheManager _cacheManager;
096    /** The context */
097    protected Context _context;
098    
099    private enum Priority
100    {
101        HIGH,
102        LOW
103    }
104    
105    private Map<String, Map<ContentType, Map<String, ViewConfigurations>>> _skinsViewConfigurations = new HashMap<>();
106    private Map<String, Map<ContentType, Map<String, View>>> _skinsViews = new HashMap<>();
107    
108    @Override
109    public void service(ServiceManager manager) throws ServiceException
110    {
111        super.service(manager);
112        
113        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
114        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
115        _contentTypesParserHelper = (ContentTypesParserHelper) manager.lookup(ContentTypesParserHelper.ROLE);
116        _srcResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
117        _skinConfigurationHelper = (SkinConfigurationHelper) manager.lookup(SkinConfigurationHelper.ROLE);
118        _skinContentTypeOverridesExtensionPoint = (SkinContentTypeOverridesExtensionPoint) manager.lookup(SkinContentTypeOverridesExtensionPoint.ROLE);
119        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
120        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
121    }
122    
123    public void contextualize(Context context) throws ContextException
124    {
125        _context = context;
126    }
127    
128    /**
129     * Initialize
130     */
131    @Override
132    public void initialize()
133    {
134        _createCaches();
135    }
136    
137    /**
138     * Creates the caches
139     */
140    protected void _createCaches()
141    {
142        _cacheManager.createMemoryCache(__VIEW_CACHE,
143                new I18nizableText("plugin.web", "PLUGINS_WEB_SKIN_CONTENTTYPE_VIEW_CACHE_LABEL"),
144                new I18nizableText("plugin.web", "PLUGINS_WEB_SKIN_CONTENTTYPE_VIEW_CACHE_DESCRIPTION"),
145                true,
146                null);
147    }
148    
149    @Override
150    public void initializeViewConfigurations() throws ConfigurationException
151    {
152        super.initializeViewConfigurations();
153        
154        for (String skinId : _skinsManager.getSkins())
155        {
156            Map<ContentType, Map<String, ViewConfigurations>> skinViewConfigurations = new HashMap<>();
157            
158            for (String contentTypeId : _contentTypeExtensionPoint.getExtensionsIds())
159            {
160                ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId);
161
162                // Look for overrides in the skin
163                List<Pair<Priority, ConfigurationAndPluginName>> overrideConfigurationsFromSkinFolder = _getOverrideConfigurationsFromSkinFolder(skinId, contentType);
164                List<ConfigurationAndPluginName> overrideConfigurationsFromSkinExtensions = _getOverrideConfigurationsFromSkinExtensions(skinId, contentType);
165                
166                if (!overrideConfigurationsFromSkinFolder.isEmpty() || !overrideConfigurationsFromSkinExtensions.isEmpty())
167                {
168                    // If there are overrides in the skin
169
170                    // 1. Get configurations from content type's extensions and _override folder)
171                    Map<String, ViewConfigurations> viewConfigurationsFromContentTypeExtensions = contentType.getViewConfigurationsFromExtensions();
172
173                    // 2. Get views overrides from skin with low priority 
174                    List<ConfigurationAndPluginName> lowPriorityOverrideConfiguration = _filterOverrideConfigurationsFromSkinFolder(overrideConfigurationsFromSkinFolder, Priority.LOW);
175                    Map<String, ViewConfigurations> lowPriorityOverrideViewConfigurations = _contentTypesParserHelper.getApplicableViewConfigurations(contentType, lowPriorityOverrideConfiguration);
176                    
177                    // Merge configurations from content type extensions with low priority configurations from skin _override folder
178                    Map<String, ViewConfigurations> merge1 = _contentTypesParserHelper.mergeViewConfigurationsWithOverrides(viewConfigurationsFromContentTypeExtensions, lowPriorityOverrideViewConfigurations);
179                    
180                    // 3. Get overrides from extensions specific to the skin
181                    Map<String, ViewConfigurations> viewConfigurationsFromOverrideSkinExtension = _contentTypesParserHelper.getApplicableViewConfigurations(contentType, overrideConfigurationsFromSkinExtensions);
182                    
183                    // Merge configurations with specific extensions of the skin
184                    Map<String, ViewConfigurations> merge2 = _contentTypesParserHelper.mergeViewConfigurationsWithOverrides(merge1, viewConfigurationsFromOverrideSkinExtension);
185
186                    // 4. Get configurations from content type's extensions and _override folder)
187                    Map<String, ViewConfigurations> projectViewConfigurations = contentType.getViewConfigurationsFromOverrideFolder();
188                    
189                    // Merge configurations with _override folder
190                    Map<String, ViewConfigurations> merge3 = _contentTypesParserHelper.mergeViewConfigurationsWithOverrides(merge2, projectViewConfigurations);
191                    
192                    // 5. Get views overrides from skin with high priority
193                    List<ConfigurationAndPluginName> highPriorityOverrideConfigurations = _filterOverrideConfigurationsFromSkinFolder(overrideConfigurationsFromSkinFolder, Priority.HIGH);
194                    Map<String, ViewConfigurations> highPriorityOverrideViewConfigurations = _contentTypesParserHelper.getApplicableViewConfigurations(contentType, highPriorityOverrideConfigurations);
195                    
196                    // Merge configurations with high priority configurations from skin _override folder
197                    Map<String, ViewConfigurations> merge4 = _contentTypesParserHelper.mergeViewConfigurationsWithOverrides(merge3, highPriorityOverrideViewConfigurations);
198                    
199                    skinViewConfigurations.put(contentType, merge4);
200                }
201            }
202            
203            if (!skinViewConfigurations.isEmpty())
204            {
205                _skinsViewConfigurations.put(skinId, skinViewConfigurations);
206            }
207        }
208    }
209    
210    private List<Pair<Priority, ConfigurationAndPluginName>> _getOverrideConfigurationsFromSkinFolder(String skinId, ContentType contentType) throws ConfigurationException
211    {
212        List<Pair<Priority, ConfigurationAndPluginName>> overriddenConfigurations = new ArrayList<>();
213        String filePath = "content-types-views/_override/" + contentType.getId() + ".xml";
214        
215        Skin skin = _skinsManager.getSkin(skinId);
216        for (Skin skinOrParentSkin : _skinsManager.getSkinAndParents(skin))
217        {
218            Source src = null;
219            try
220            {
221                String confFileUri = "skin-raw:" + skinOrParentSkin.getId() + "://" + filePath;
222                src = _srcResolver.resolveURI(confFileUri);
223    
224                if (src.exists())
225                {
226                    try (InputStream is = src.getInputStream())
227                    {
228                        Configuration configuration = new DefaultConfigurationBuilder(true).build(is);
229                        
230                        // Check that only view overrides are present in the configuration
231                        for (Configuration child : configuration.getChildren())
232                        {
233                            if (!"view".equals(child.getName()))
234                            {
235                                throw new ConfigurationException("The overridden configuration at content-types-views/_override/" + contentType.getId() + ".xml for skin '" + skinOrParentSkin.getId() + "' should contain onlmy view overrides");
236                            }
237                        }
238                        
239                        // Get priority level of the override, default to high if not specified
240                        String priorityAsString = configuration.getAttribute("priority", "high");
241                        Priority priority = Priority.valueOf(priorityAsString.toUpperCase());
242                        
243                        overriddenConfigurations.add(new ImmutablePair<>(priority, new ConfigurationAndPluginName(configuration, StringUtils.EMPTY, "skin." + skinOrParentSkin.getId())));
244                    }
245                }
246            }
247            catch (Exception e)
248            {
249                throw new ConfigurationException("Unable to parse overridden configuration for content type '" + contentType.getId() + "' and skin '" + skinId + "' at /content-types-views/_override/" + contentType.getId() + ".xml", e);
250            }
251            finally
252            {
253                _srcResolver.release(src);
254            }
255        }
256        
257        return overriddenConfigurations.reversed();
258    }
259    
260    private List<ConfigurationAndPluginName> _filterOverrideConfigurationsFromSkinFolder(List<Pair<Priority, ConfigurationAndPluginName>> configurations, Priority priority)
261    {
262        return configurations.stream()
263                             .filter(pair -> priority.equals(pair.getLeft()))
264                             .map(Pair::getRight)
265                             .toList();
266    }
267    
268    private List<ConfigurationAndPluginName> _getOverrideConfigurationsFromSkinExtensions(String skinId, ContentType contentType)
269    {
270        return _skinContentTypeOverridesExtensionPoint.getOverrideConfigurations(skinId, contentType.getId());
271    }
272    
273    @Override
274    public Map<String, ViewConfigurations> getViewConfigurations(ContentType contentType, ViewParserContext context)
275    {
276        if (context instanceof WebViewParserContext webContext && webContext.getSkinId().isPresent())
277        {
278            Map<ContentType, Map<String, ViewConfigurations>> skinViewConfigurations = _skinsViewConfigurations.get(webContext.getSkinId().get());
279            if (skinViewConfigurations.containsKey(contentType))
280            {
281                return skinViewConfigurations.get(contentType);
282            }
283        }
284
285        // No skin, or content type not overridden in the skin
286        return super.getViewConfigurations(contentType, context);
287    }
288    
289    public void parseViews() throws ConfigurationException
290    {
291        super.parseViews();
292        
293        for (String skinId : _skinsViewConfigurations.keySet())
294        {
295            Map<ContentType, Map<String, ViewConfigurations>> contentTypesViewConfigurations = _skinsViewConfigurations.get(skinId);
296            ViewParserContext viewParserContext = WebViewParserContext.newInstance()
297                                                                      .withSkinId(skinId);
298
299            Map<ContentType, Map<String, View>> contentTypesViews = new HashMap<>();
300            for (ContentType contentType : contentTypesViewConfigurations.keySet())
301            {
302                Map<ContentType, Map<String, View>> contentTypeViews = _parseContentTypeViews(skinId, contentType, viewParserContext);
303                contentTypesViews.putAll(contentTypeViews);
304            }
305            
306            _skinsViews.put(skinId, contentTypesViews);
307        }
308    }
309    
310    private Map<ContentType, Map<String, View>> _parseContentTypeViews(String skinId, ContentType contentType, ViewParserContext viewParserContext) throws ConfigurationException
311    {
312        Map<ContentType, Map<String, View>> contentTypeViews = new HashMap<>();
313        
314        Map<String, View> views = new LinkedHashMap<>();
315        
316        Map<String, ViewConfigurationsByType> viewConfigurations = _contentTypesParserHelper.mergeViewConfigurationsFromCurrentTypeAndSuperTypes(contentType, viewParserContext);
317        for (String viewName : viewConfigurations.keySet())
318        {
319            ViewConfigurationsByType viewConfigurationsByType = viewConfigurations.get(viewName);
320            View view = _contentTypesParserHelper.parseView(contentType, viewConfigurationsByType, viewParserContext);
321            views.put(viewName, view);
322        }
323        
324        contentTypeViews.put(contentType, views);
325        
326        for (ContentType child : _getContentTypeChildren(contentType))
327        {
328            if (_isThereOverridesForContentTypeInSkin(skinId, contentType))
329            {
330                Map<ContentType, Map<String, View>> childViews = _parseContentTypeViews(skinId, child, viewParserContext);
331                contentTypeViews.putAll(childViews);
332            }
333        }
334        
335        return contentTypeViews;
336    }
337    
338    private List<ContentType> _getContentTypeChildren(ContentType contentType)
339    {
340        List<ContentType> children = new ArrayList<>();
341        
342        for (String childId : _contentTypeExtensionPoint.getExtensionsIds())
343        {
344            ContentType child = _contentTypeExtensionPoint.getExtension(childId);
345            if (ArrayUtils.contains(child.getSupertypeIds(), contentType.getId()))
346            {
347                children.add(child);
348            }
349        }
350        
351        return children;
352    }
353    
354    private boolean _isThereOverridesForContentTypeInSkin(String skinId, ContentType contentType)
355    {
356        return _skinsViewConfigurations.containsKey(skinId) && _skinsViewConfigurations.get(skinId).containsKey(contentType);
357    }
358    
359    public void resolveViewReferences() throws ConfigurationException
360    {
361        for (String skinId : _skinsViews.keySet())
362        {
363            Map<ContentType, Map<String, View>> contentTypesViews = _skinsViews.get(skinId);
364            ViewParserContext context = WebViewParserContext.newInstance()
365                                                            .withSkinId(skinId);
366            
367            for (ContentType contentType : contentTypesViews.keySet())
368            {
369                Map<String, View> views = contentTypesViews.get(contentType);
370                for (String viewName : views.keySet())
371                {
372                    View view = views.get(viewName);
373                    _contentTypesParserHelper.resolveViewReferences(contentType, view, viewName, context);
374                }
375            }
376        }
377    }
378    
379    public View getContentTypeView(ContentType contentType, String viewName, ViewParserContext context)
380    {
381        return Optional.ofNullable(context)
382                       .filter(WebViewParserContext.class::isInstance)
383                       .map(WebViewParserContext.class::cast)
384                       .map(webContext -> webContext.getSkinId())
385                       .filter(Optional::isPresent)
386                       .map(Optional::get)
387                       .map(skinId -> _getSkinView(skinId, contentType, viewName))
388                       .filter(Optional::isPresent)
389                       .map(Optional::get)
390                       .orElseGet(() -> super.getContentTypeView(contentType, viewName, context));
391    }
392    
393    @Override
394    public Set<String> getOverriddenContentTypes()
395    {
396        Set<String> overriddenContentTypes = super.getOverriddenContentTypes();
397        overriddenContentTypes.addAll(_skinContentTypeOverridesExtensionPoint.getOverriddenContentTypes());
398        return overriddenContentTypes;
399    }
400    
401    @Override
402    public View getView(String viewName, String[] contentTypeIds, String[] mixinIds)
403    {
404        Request request = ContextHelper.getRequest(_context);
405        String siteName = WebHelper.getSiteName(request);
406        
407        Optional<View> skinView = Optional.empty();
408        if (StringUtils.isNotBlank(siteName) && _siteManager.hasSite(siteName))
409        {
410            Site site = _siteManager.getSite(siteName);
411            String skinId = site.getSkinId();
412            
413            CacheKey cacheKey = CacheKey.of(skinId, Set.of(contentTypeIds), Set.of(mixinIds), viewName);
414            skinView = _getViewCache().get(cacheKey,  __ -> _getSkinView(skinId, viewName, contentTypeIds, mixinIds));
415        }
416        
417        return skinView.orElseGet(() -> super.getView(viewName, contentTypeIds, mixinIds));
418    }
419    
420    private Optional<View> _getSkinView(String skinId, String viewName, String[] contentTypeIds, String[] mixinIds)
421    {
422        if (_isViewOverridden(skinId, contentTypeIds, mixinIds, viewName))
423        {
424            ViewParserContext context = WebViewParserContext.newInstance()
425                                                            .withSkinId(skinId);
426            List<View> views = new ArrayList<>();
427            for (String contentTypeId : contentTypeIds)
428            {
429                ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId);
430                views.add(getContentTypeView(contentType, viewName, context));
431            }
432    
433            for (String mixinId : mixinIds)
434            {
435                ContentType mixin = _contentTypeExtensionPoint.getExtension(mixinId);
436                views.add(getContentTypeView(mixin, viewName, context));
437            }
438    
439            return Optional.of(_contentTypesHelper.joinViews(views));
440        }
441        else
442        {
443            return Optional.empty();
444        }
445    }
446
447    private boolean _isViewOverridden(String skinId, String[] contentTypeIds, String[] mixinIds, String viewName)
448    {
449        if (_skinsViews.containsKey(skinId))
450        {
451            Map<ContentType, Map<String, View>> contentTypesViews = _skinsViews.get(skinId);
452            for (String contentTypeId : contentTypeIds)
453            {
454                if (_isViewOverridden(contentTypeId, viewName, contentTypesViews))
455                {
456                    return true;
457                }
458            }
459
460            for (String mixinId : mixinIds)
461            {
462                if (_isViewOverridden(mixinId, viewName, contentTypesViews))
463                {
464                    return true;
465                }
466            }
467        }
468        
469        return false;
470    }
471    
472    private boolean _isViewOverridden(String contentTypeId, String viewName, Map<ContentType, Map<String, View>> views)
473    {
474        ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId);
475        return views.containsKey(contentType) && views.get(contentType).containsKey(viewName);
476    }
477    
478    private Optional<View> _getSkinView(String skinId, ContentType contentType, String viewName)
479    {
480        Optional<View> skinView = Optional.empty();
481        
482        if (_skinsViews.containsKey(skinId))
483        {
484            Map<ContentType, Map<String, View>> skinViews = _skinsViews.get(skinId);
485            if (skinViews.containsKey(contentType))
486            {
487                Map<String, View> contentTypeViews = skinViews.get(contentType);
488                if (contentTypeViews.containsKey(viewName))
489                {
490                    skinView = Optional.of(contentTypeViews.get(viewName));
491                }
492            }
493        }
494        
495        return skinView;
496    }
497    
498    private Cache<CacheKey, Optional<View>> _getViewCache()
499    {
500        return _cacheManager.get(__VIEW_CACHE);
501    }
502    
503    static final class CacheKey extends AbstractCacheKey
504    {
505        private CacheKey(String skinId, Set<String> contentTypeIds, Set<String> mixinIds, String viewName)
506        {
507            super(skinId, contentTypeIds, mixinIds, viewName);
508        }
509
510        static CacheKey of(String skinId, Set<String> contentTypeIds, Set<String> mixinIds, String viewName)
511        {
512            return new CacheKey(skinId, contentTypeIds, mixinIds, viewName);
513        }
514    }
515}