001/*
002 *  Copyright 2016 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.plugins.userdirectory;
017
018import java.text.Normalizer;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Set;
027import java.util.SortedSet;
028import java.util.TreeSet;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031import java.util.stream.Collectors;
032
033import org.apache.avalon.framework.activity.Initializable;
034import org.apache.avalon.framework.component.Component;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.commons.lang3.StringUtils;
039
040import org.ametys.cms.contenttype.ContentType;
041import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ContentTypeExpression;
044import org.ametys.cms.repository.LanguageExpression;
045import org.ametys.core.cache.AbstractCacheManager;
046import org.ametys.core.cache.Cache;
047import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
048import org.ametys.plugins.repository.AmetysObjectIterable;
049import org.ametys.plugins.repository.AmetysObjectResolver;
050import org.ametys.plugins.repository.AmetysRepositoryException;
051import org.ametys.plugins.repository.UnknownAmetysObjectException;
052import org.ametys.plugins.repository.provider.WorkspaceSelector;
053import org.ametys.plugins.repository.query.QueryHelper;
054import org.ametys.plugins.repository.query.SortCriteria;
055import org.ametys.plugins.repository.query.expression.AndExpression;
056import org.ametys.plugins.repository.query.expression.Expression;
057import org.ametys.plugins.repository.query.expression.Expression.Operator;
058import org.ametys.plugins.repository.query.expression.OrExpression;
059import org.ametys.plugins.repository.query.expression.StringExpression;
060import org.ametys.plugins.repository.query.expression.VirtualFactoryExpression;
061import org.ametys.plugins.userdirectory.page.VirtualUserDirectoryPageFactory;
062import org.ametys.runtime.i18n.I18nizableText;
063import org.ametys.runtime.plugin.component.AbstractLogEnabled;
064import org.ametys.web.repository.page.Page;
065import org.ametys.web.repository.page.PageQueryHelper;
066
067/**
068 * Component providing methods to retrieve user directory virtual pages, such as the user directory root,
069 * transitional page and user page.
070 */
071public class UserDirectoryPageHandler extends AbstractLogEnabled implements Component, Serviceable, Initializable
072{
073    /** The avalon role. */
074    public static final String ROLE = UserDirectoryPageHandler.class.getName();
075    
076    /** The data name for the content type of the user directory */
077    public static final String CONTENT_TYPE_DATA_NAME = "user-directory-root-contenttype";
078    /** The data name for the users' view to use */
079    public static final String USER_VIEW_NAME = "user-directory-root-view-name";
080    /** The data name for the classification attribute of the user directory */
081    public static final String CLASSIFICATION_ATTRIBUTE_DATA_NAME = "user-directory-root-classification-metadata";
082    /** The data name for the depth of the user directory */
083    public static final String DEPTH_DATA_NAME = "user-directory-root-depth";
084    /** The user directory root pages cache id */
085    protected static final String ROOT_PAGES_CACHE = UserDirectoryPageHandler.class.getName() + "$rootPageIds";
086    /** The user directory user pages cache id */
087    protected static final String UD_PAGES_CACHE = UserDirectoryPageHandler.class.getName() + "$udPages";
088    
089    /** The workspace selector. */
090    protected WorkspaceSelector _workspaceSelector;
091    /** The ametys object resolver. */
092    protected AmetysObjectResolver _resolver;
093    /** The extension point for content types */
094    protected ContentTypeExtensionPoint _contentTypeEP;
095    /** The cache manager */
096    protected AbstractCacheManager _abstractCacheManager;
097    
098    @Override
099    public void service(ServiceManager manager) throws ServiceException
100    {
101        _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE);
102        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
103        _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
104        _abstractCacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
105    }
106    
107    @Override
108    public void initialize() throws Exception
109    {
110        _abstractCacheManager.createMemoryCache(ROOT_PAGES_CACHE, 
111                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_CACHE_ROOT_PAGES_LABEL"),
112                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_CACHE_ROOT_PAGES_DESCRIPTION"),
113                true,
114                null);
115        _abstractCacheManager.createMemoryCache(UD_PAGES_CACHE, 
116                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_CACHE_UD_PAGES_LABEL"),
117                new I18nizableText("plugin.user-directory", "PLUGINS_USER_DIRECTORY_CACHE_UD_PAGES_DESCRIPTION"),
118                true,
119                null);
120    }
121    
122    /**
123     * Gets the user directory root pages from the given content type id, whatever the site.
124     * @param contentTypeId The content type id
125     * @return the user directory root pages.
126     * @throws AmetysRepositoryException  if an error occured.
127     */
128    public Set<Page> getUserDirectoryRootPages(String contentTypeId) throws AmetysRepositoryException
129    {
130        Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName());
131        Expression contentTypeExp = new StringExpression(CONTENT_TYPE_DATA_NAME, Operator.EQ, contentTypeId);
132        
133        AndExpression andExp = new AndExpression(expression, contentTypeExp);
134        
135        String query = PageQueryHelper.getPageXPathQuery(null, null, null, andExp, null);
136        
137        AmetysObjectIterable<Page> pages = _resolver.query(query);
138        
139        return pages.stream().collect(Collectors.toSet());
140    }
141    
142    /**
143     * Gets the user directory root page of a specific content type.
144     * @param siteName The site name
145     * @param sitemapName The sitemap
146     * @param contentTypeId The content type id
147     * @return the user directory root pages.
148     * @throws AmetysRepositoryException  if an error occured.
149     */
150    public Page getUserDirectoryRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException
151    {
152        String contentTypeIdToCompare = contentTypeId != null ? contentTypeId : "";
153        
154        for (Page userDirectoryRootPage : getUserDirectoryRootPages(siteName, sitemapName))
155        {
156            if (contentTypeIdToCompare.equals(getContentTypeId(userDirectoryRootPage)))
157            {
158                return userDirectoryRootPage;
159            }
160        }
161        
162        return null;
163    }
164    
165    /**
166     * Gets the user directory root pages.
167     * @param siteName The site name
168     * @param sitemapName The sitemap
169     * @return the user directory root pages.
170     * @throws AmetysRepositoryException  if an error occured.
171     */
172    public Set<Page> getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException
173    {
174        Set<Page> rootPages = new HashSet<>();
175        
176        String workspace = _workspaceSelector.getWorkspace();
177        
178        Cache<RootPageCacheKey, Set<String>> cache = getRootPagesCache();
179        
180        RootPageCacheKey key = RootPageCacheKey.of(workspace, siteName, sitemapName);
181        if (cache.hasKey(key))
182        {
183            rootPages = cache.get(key).stream()
184                    .map(this::_resolvePage)
185                    .filter(Objects::nonNull)
186                    .collect(Collectors.toSet());
187        }
188        else
189        {
190            rootPages = _getUserDirectoryRootPages(siteName, sitemapName);
191            Set<String> userDirectoryRootPageIds = rootPages.stream()
192                    .map(Page::getId)
193                    .collect(Collectors.toSet());
194            cache.put(key, userDirectoryRootPageIds);
195        }
196        
197        return rootPages;
198    }
199    
200    private Page _resolvePage(String pageId)
201    {
202        try
203        {
204            return _resolver.resolveById(pageId);
205        }
206        catch (UnknownAmetysObjectException e)
207        {
208            // The page stored in cache may have been deleted
209            return null;
210        }
211    }
212    
213    /**
214     * Get the user directory root pages, without searching in the cache.
215     * @param siteName the current site.
216     * @param sitemapName the sitemap name.
217     * @return the user directory root pages
218     * @throws AmetysRepositoryException if an error occured.
219     */
220    protected Set<Page> _getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException
221    {
222        Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName());
223        
224        String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null);
225        
226        AmetysObjectIterable<Page> pages = _resolver.query(query);
227        
228        return pages.stream().collect(Collectors.toSet());
229    }
230    
231    /**
232     * Gets the depth of the user directory root page
233     * @param rootPage The user directory root page
234     * @return the depth of the user directory root page
235     */
236    public int getDepth(Page rootPage)
237    {
238        return Math.toIntExact(rootPage.getValue(DEPTH_DATA_NAME));
239    }
240    
241    /**
242     * Gets the name of the classification attribute
243     * @param rootPage The user directory root page
244     * @return the name of the classification attribute
245     */
246    public String getClassificationAttribute(Page rootPage)
247    {
248        return rootPage.getValue(CLASSIFICATION_ATTRIBUTE_DATA_NAME);
249    }
250    
251    /**
252     * Gets the content type id
253     * @param rootPage The user directory root page
254     * @return the content type id
255     */
256    public String getContentTypeId(Page rootPage)
257    {
258        return rootPage.getValue(CONTENT_TYPE_DATA_NAME);
259    }
260    
261    /**
262     * Gets the content type
263     * @param rootPage The user directory root page
264     * @return the content type
265     */
266    public ContentType getContentType(Page rootPage)
267    {
268        String contentTypeId = getContentTypeId(rootPage);
269        return StringUtils.isNotBlank(contentTypeId) ? _contentTypeEP.getExtension(contentTypeId) : null;
270    }
271    
272    /**
273     * Gets the value of the classification attribute for the given content, transformed for building tree hierarchy
274     * <br>The transformation takes the lower-case of all characters, removes non-alphanumeric characters,
275     * and takes the first characters to not have a string with a size bigger than the depth
276     * <br>For instance, if the value for the content is "Aéa Foo-bar" and the depth is 7,
277     * then this method will return "aeafoob"
278     * @param rootPage The user directory root page
279     * @param content The content
280     * @return the transformed value of the classification attribute for the given content. Can be null
281     */
282    public String getTransformedClassificationValue(Page rootPage, Content content)
283    {
284        String attribute = getClassificationAttribute(rootPage);
285        int depth = getDepth(rootPage);
286        
287        // 1) get value of the classification attribute
288        String classification = content.getValue(attribute);
289        
290        if (classification == null)
291        {
292            // The classification does not exists for the content
293            getLogger().info("The classification attribute '{}' does not exist for the content {}", attribute, content);
294            return null;
295        }
296        
297        try
298        {
299            // 2) replace special character
300            // 3) remove '-' characters
301            
302            // FIXME CMS-5758 FilterNameHelper.filterName do not authorized name with numbers only.
303            // So code of FilterNamehelper is temporarily duplicated here with a slightly modified RegExp
304//            String transformedValue = FilterNameHelper.filterName(classification).replace("-", "");
305            String transformedValue = _filterName(classification).replace("-", "");
306            
307            // 4) only keep 'depth' first characters (if depth = 3, "de" becomes "de", "debu" becomes "deb", etc.)
308            return StringUtils.substring(transformedValue, 0, depth);
309        }
310        catch (IllegalArgumentException e)
311        {
312            // The value of the classification attribute is not valid
313            getLogger().warn("The classification attribute '{}' does not have a valid value ({}) for the content {}", attribute, classification, content);
314            return null;
315        }
316    }
317    
318    private String _filterName(String name)
319    {
320        Pattern pattern = Pattern.compile("^()[0-9-_]*[a-z0-9].*$");
321        // Use lower case
322        // then remove accents
323        // then replace contiguous spaces with one dash
324        // and finally remove non-alphanumeric characters except -
325        String filteredName = Normalizer.normalize(name.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 
326        filteredName = filteredName.replaceAll("œ", "oe").replaceAll("æ", "ae").replaceAll(" +", "-").replaceAll("[^\\w-]", "-").replaceAll("-+", "-");
327
328        Matcher m = pattern.matcher(filteredName);
329        if (!m.matches())
330        {
331            throw new IllegalArgumentException(filteredName + " doesn't match the expected regular expression : " + pattern.pattern());
332        }
333
334        filteredName = filteredName.substring(m.end(1));
335
336        // Remove characters '-' and '_' at the start and the end of the string
337        return StringUtils.strip(filteredName, "-_");
338    }
339    
340    /**
341     * Get all transitional page child from page name
342     * @param rootPage the root page
343     * @param pagePath the page path
344     * @return all transitional page child from page name
345     */
346    public SortedSet<String> getTransitionalPagesName(Page rootPage, String pagePath)
347    {
348        String workspace = _workspaceSelector.getWorkspace();
349        String site = rootPage.getSiteName();
350        String contentType = getContentTypeId(rootPage);
351        String lang = rootPage.getSitemapName();
352        
353        PageCacheKey key = PageCacheKey.of(workspace, contentType, site, lang);
354        UDPagesCache udCache = getUDPagesCache().get(key, k -> _getUDPages(rootPage, workspace, contentType, lang));
355
356        Map<String, SortedSet<String>> transitionalPages = udCache.transitionalPagesCache();
357        String cachePagePath = getName(pagePath);
358        return transitionalPages.getOrDefault(cachePagePath, new TreeSet<>());
359    }
360    
361    /**
362     * Get all user page child from page name
363     * @param rootPage the root page
364     * @param pagePath the page path
365     * @return all user page child from page name
366     */
367    public Map<String, String> getUserPagesContent(Page rootPage, String pagePath)
368    {
369        String workspace = _workspaceSelector.getWorkspace();
370        String site = rootPage.getSiteName();
371        String contentType = getContentTypeId(rootPage);
372        String lang = rootPage.getSitemapName();
373        
374        PageCacheKey key = PageCacheKey.of(workspace, contentType, site, lang);
375        UDPagesCache udCache = getUDPagesCache().get(key, k -> _getUDPages(rootPage, workspace, contentType, lang));
376        
377        Map<String, Map<String, String>> userPages = udCache.userPagesCache();
378        String cachePagePath = getName(pagePath);
379        return userPages.getOrDefault(cachePagePath, new HashMap<>());
380    }
381    
382    /**
383     * Get the UD cache by page path
384     * For transitional pages returning a map as {'p' : [a, e], 'p/a' : [], 'p/e' : []} 
385     * For user pages returning a map as {'p' : {userContent1: user://xxxxxxx1, userContent2: user://xxxxxxx2,}, 'p/a' : {userContent1: user://xxxxxxx1}, 'p/e' : {userContent2: user://xxxxxxx2}} 
386     * @param rootPage the root page
387     * @param workspace the workspace
388     * @param contentType the content type
389     * @param lang the language
390     * @return the UD pages cache
391     */
392    private UDPagesCache _getUDPages(Page rootPage, String workspace, String contentType, String lang)
393    {
394        // Getting all user content with its classification identifier defined in the root page
395        Map<Content, String> transformedValuesByContent = _getTransformedValuesByContent(rootPage);
396        
397        // Computing transitional pages cache
398        Set<String> transformedValues = new HashSet<>(transformedValuesByContent.values());
399        Map<String, SortedSet<String>> transitionalPagesCache = _getTransitionalPageByPagePath(transformedValues);
400
401        // Computing user pages cache
402        int depth = getDepth(rootPage);
403        Map<String, Map<String, String>> userPageCache = _getUserContentsByPagePath(transformedValuesByContent, depth);
404        
405        getLogger().info("UD pages cache was built for workspace '{}' and content type '{}' and language '{}'", workspace, contentType, lang);
406        return new UDPagesCache(transitionalPagesCache, userPageCache);
407    }
408    
409    private Map<String, SortedSet<String>> _getTransitionalPageByPagePath(Set<String> transformedValues)
410    {
411        Map<String, SortedSet<String>> transitionalPageByPath = new HashMap<>();
412        for (String value : transformedValues)
413        {
414            char[] charArray = value.toCharArray();
415            for (int i = 0; i < charArray.length; i++)
416            {
417                String lastChar = String.valueOf(charArray[i]);
418                if (i == 0)
419                {
420                    // case _root
421                    SortedSet<String> root = transitionalPageByPath.getOrDefault("_root", new TreeSet<>());
422                    if (!root.contains(lastChar))
423                    {
424                        root.add(lastChar);
425                    }
426                    transitionalPageByPath.put("_root", root);
427                }
428                else
429                {
430                    String currentPrefixWithoutLastChar = value.substring(0, i); // if value == "debu", equals to "deb"
431                    String currentPathWithoutLastChar = StringUtils.join(currentPrefixWithoutLastChar.toCharArray(), '/'); // if value == "debu", equals to "d/e/b"
432                    SortedSet<String> childPageNames = transitionalPageByPath.getOrDefault(currentPathWithoutLastChar, new TreeSet<>());
433                    if (!childPageNames.contains(lastChar))
434                    {
435                        childPageNames.add(lastChar); // if value == "debu", add "u" in childPageNames for key "d/e/b"
436                    }
437                    transitionalPageByPath.put(currentPathWithoutLastChar, childPageNames);
438                }
439            }
440        }
441        
442        return transitionalPageByPath;
443    }
444    
445    private Map<String, Map<String, String>> _getUserContentsByPagePath(Map<Content, String> transformedValuesByContent, int depth)
446    {
447        Map<String, Map<String, String>> contentsByPath = new LinkedHashMap<>();
448        if (depth == 0)
449        {
450            Map<String, String> rootContents = new LinkedHashMap<>();
451            for (Content content : transformedValuesByContent.keySet())
452            {
453                rootContents.put(content.getName(), content.getId());
454            }
455            
456            contentsByPath.put("_root", rootContents);
457            return contentsByPath;
458        }
459        
460        for (Content content : transformedValuesByContent.keySet())
461        {
462            String transformedValue = transformedValuesByContent.get(content);
463            for (int i = 0; i < depth; i++)
464            {
465                String currentPrefix = StringUtils.substring(transformedValue, 0, i + 1);
466                String currentPath = StringUtils.join(currentPrefix.toCharArray(), '/');
467                Map<String, String> contentsForPath = contentsByPath.getOrDefault(currentPath, new LinkedHashMap<>());
468                
469                String contentName = content.getName();
470                if (!contentsForPath.containsKey(contentName))
471                {
472                    contentsForPath.put(contentName, content.getId());
473                }
474                contentsByPath.put(currentPath, contentsForPath);
475            }
476        }
477        return contentsByPath;
478    }
479    
480    /**
481     * Get all transformed values by content
482     * @param rootPage the root page 
483     * @return the map of transformed values by content
484     */
485    protected Map<Content, String> _getTransformedValuesByContent(Page rootPage)
486    {
487        // Get all contents which will appear in the sitemap
488        AmetysObjectIterable<Content> contents = getContentsForRootPage(rootPage);
489        
490        // Get their classification attribute value
491        Map<Content, String> transformedValuesByContent = new LinkedHashMap<>();
492        for (Content content : contents)
493        {
494            String value = getTransformedClassificationValue(rootPage, content);
495            if (value != null)
496            {
497                transformedValuesByContent.put(content, value);
498            }
499        }
500        return transformedValuesByContent;
501    }
502
503    /**
504     * Get the user contents for a given root page
505     * @param rootPage the root page
506     * @return the user contents
507     */
508    public AmetysObjectIterable<Content> getContentsForRootPage(Page rootPage)
509    {
510        String contentType = getContentTypeId(rootPage);
511        String lang = rootPage.getSitemapName();
512        
513        Set<String> subTypes = _contentTypeEP.getSubTypes(contentType);
514        
515        List<Expression> contentTypeExpressions = new ArrayList<>();
516        contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, contentType));
517        for (String subType : subTypes)
518        {
519            contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, subType));
520        }
521        
522        Expression contentTypeExpression = new OrExpression(contentTypeExpressions.toArray(new Expression[subTypes.size() + 1]));
523        
524        Expression finalExpr = new AndExpression(contentTypeExpression, new LanguageExpression(Operator.EQ, lang));
525        
526        SortCriteria sort = new SortCriteria();
527        sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
528        
529        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort);
530        
531        return _resolver.query(xPathQuery);
532    }
533    
534    /**
535     * Gets name form path name
536     * @param pathName the path name
537     * @return the name
538     */
539    public String getName(String pathName)
540    {
541        String prefix = "page-";
542        String name = "";
543        for (String transitionalPageName : pathName.split("/"))
544        {
545            if (!name.equals(""))
546            {
547                name += "/";
548            }
549            name += StringUtils.startsWith(transitionalPageName, prefix) ? StringUtils.substringAfter(transitionalPageName, prefix) : transitionalPageName;
550        }
551        return name;
552    }
553    
554    /**
555     * Checks if name contains only Unicode digits and if so, prefix it with "page-"
556     * @param name The page name
557     * @return The potentially prefixed page name
558     */
559    public String getPathName(String name)
560    {
561        return StringUtils.isNumeric(name) ? "page-" + name : name; 
562    }
563    
564    /**
565     * Clear root page cache
566     * @param rootPage the root page
567     */
568    public void clearCache(Page rootPage)
569    {
570        clearCache(getContentTypeId(rootPage));
571    }
572    
573    /**
574     * Clear root page cache
575     * @param contentTypeId the content type id
576     */
577    public void clearCache(String contentTypeId)
578    {
579        getUDPagesCache().invalidate(PageCacheKey.of(null, contentTypeId, null, null));
580        
581        getRootPagesCache().invalidateAll();
582    }
583    
584    /**
585     * Cache of the user directory root pages.
586     * The cache store a Set of TODO indexed by the workspaceName, siteName, siteMapName
587     * @return the cache
588     */
589    protected Cache<RootPageCacheKey, Set<String>> getRootPagesCache()
590    {
591        return _abstractCacheManager.get(ROOT_PAGES_CACHE);
592    }
593    
594    /**
595     * Key to index a user directory root page in a cache
596     */
597    protected static final class RootPageCacheKey extends AbstractCacheKey
598    {
599        /**
600         * Basic constructor
601         * @param workspaceName the workspace name. Can be null.
602         * @param siteName the site name. Can be null.
603         * @param language the sitemap name. Can be null.
604         */
605        public RootPageCacheKey(String workspaceName, String siteName, String language)
606        {
607            super(workspaceName, siteName, language);
608        }
609        
610        /**
611         * Generate a cache key
612         * @param workspaceName the workspace name. Can be null.
613         * @param siteName the site name. Can be null.
614         * @param language the sitemap name. Can be null.
615         * @return the cache key
616         */
617        public static RootPageCacheKey of(String workspaceName, String siteName, String language)
618        {
619            return new RootPageCacheKey(workspaceName, siteName, language);
620        }
621    }
622    
623    /**
624     * Cache of the user directory user pages and transitional page.
625     * The cache store a {@link UDPagesCache} containing the transitional pages cache and the user pages cache.
626     * The cache is indexed by workspaceName, siteName, siteMapName, pageName.
627     * @return the cache
628     */
629    protected Cache<PageCacheKey, UDPagesCache> getUDPagesCache()
630    {
631        return _abstractCacheManager.get(UD_PAGES_CACHE);
632    }
633    
634    /**
635     * Key to index a user directory page in a cache
636     */
637    protected static final class PageCacheKey extends AbstractCacheKey
638    {
639        /**
640         * Basic constructor
641         * @param workspaceName the workspace name. Can be null.
642         * @param contentTypeId the contentType id. Can be null.
643         * @param siteName the site name. Can be null.
644         * @param language the sitemap name. Can be null.
645         */
646        public PageCacheKey(String workspaceName, String contentTypeId, String siteName, String language)
647        {
648            super(workspaceName, contentTypeId, siteName, language);
649        }
650        
651        /**
652         * Generate a cache key
653         * @param workspaceName the workspace name. Can be null.
654         * @param contentTypeId the contentType id. Can be null.
655         * @param siteName the site name. Can be null.
656         * @param language the sitemap name. Can be null.
657         * @return the cache key
658         */
659        public static PageCacheKey of(String workspaceName, String contentTypeId, String siteName, String language)
660        {
661            return new PageCacheKey(workspaceName, contentTypeId, siteName, language);
662        }
663    }
664    
665    /**
666     * User directory pages cache 
667     * @param transitionalPagesCache the cache for transitional pages. The cache store a {@link Map} of (content path, sorted set of transitional page path).
668     * @param userPagesCache the cache for user pages. The cache store a {@link Map} of (content path, (content name, content id)) of all the content of the page.
669     */
670    protected record UDPagesCache(Map<String, SortedSet<String>> transitionalPagesCache, Map<String, Map<String, String>> userPagesCache) { /** */ }
671}