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