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