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