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