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 data name for the content type of the user directory */
073    public static final String CONTENT_TYPE_DATA_NAME = "user-directory-root-contenttype";
074    /** The data name for the users' view to use */
075    public static final String USER_VIEW_NAME = "user-directory-root-view-name";
076    /** The data name for the classification attribute of the user directory */
077    public static final String CLASSIFICATION_ATTRIBUTE_DATA_NAME = "user-directory-root-classification-metadata";
078    /** The data name for the depth of the user directory */
079    public static final String DEPTH_DATA_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; site --&gt; language --&gt; (key : page name -- list&lt;&gt; children pages names)  */
093    protected Map<String, Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>>> _transitionalPagesCache;
094    /** The list of user page  workspace --&gt; contentType --&gt; site --&gt; language --&gt; (key : page name --&gt; Map(page name, content id)) */
095    protected Map<String, Map<String, 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_DATA_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 have been 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_DATA_NAME));
253    }
254    
255    /**
256     * Gets the name of the classification attribute
257     * @param rootPage The user directory root page
258     * @return the name of the classification attribute
259     */
260    public String getClassificationAttribute(Page rootPage)
261    {
262        return rootPage.getValue(CLASSIFICATION_ATTRIBUTE_DATA_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_DATA_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 attribute 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 attribute for the given content. Can be null
295     */
296    public String getTransformedClassificationValue(Page rootPage, Content content)
297    {
298        String attribute = getClassificationAttribute(rootPage);
299        int depth = getDepth(rootPage);
300        
301        // 1) get value of the classification attribute
302        String classification = content.getValue(attribute);
303        
304        if (classification == null)
305        {
306            // The classification does not exists for the content
307            getLogger().info("The classification attribute '{}' does not exist for the content {}", attribute, content);
308            return null;
309        }
310        
311        try
312        {
313            // 2) replace special character
314            // 3) remove '-' characters
315            
316            // FIXME CMS-5758 FilterNameHelper.filterName do not authorized name with numbers only.
317            // So code of FilterNamehelper is temporarily duplicated here with a slightly modified RegExp
318//            String transformedValue = FilterNameHelper.filterName(classification).replace("-", "");
319            String transformedValue = _filterName(classification).replace("-", "");
320            
321            // 4) only keep 'depth' first characters (if depth = 3, "de" becomes "de", "debu" becomes "deb", etc.)
322            return StringUtils.substring(transformedValue, 0, depth);
323        }
324        catch (IllegalArgumentException e)
325        {
326            // The value of the classification attribute is not valid
327            getLogger().warn("The classification attribute '{}' does not have a valid value ({}) for the content {}", attribute, classification, content);
328            return null;
329        }
330    }
331    
332    private String _filterName(String name)
333    {
334        Pattern pattern = Pattern.compile("^()[0-9-_]*[a-z0-9].*$");
335        // Use lower case
336        // then remove accents
337        // then replace contiguous spaces with one dash
338        // and finally remove non-alphanumeric characters except -
339        String filteredName = Normalizer.normalize(name.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 
340        filteredName = filteredName.replaceAll("œ", "oe").replaceAll("æ", "ae").replaceAll(" +", "-").replaceAll("[^\\w-]", "-").replaceAll("-+", "-");
341
342        Matcher m = pattern.matcher(filteredName);
343        if (!m.matches())
344        {
345            throw new IllegalArgumentException(filteredName + " doesn't match the expected regular expression : " + pattern.pattern());
346        }
347
348        filteredName = filteredName.substring(m.end(1));
349
350        // Remove characters '-' and '_' at the start and the end of the string
351        return StringUtils.strip(filteredName, "-_");
352    }
353    
354    /**
355     * Get all transitional page child from page name
356     * @param rootPage the root page
357     * @param pagePath the page path
358     * @return all transitional page child from page name
359     */
360    public SortedSet<String> getTransitionalPagesName(Page rootPage, String pagePath)
361    {
362        String cachePagePath = getName(pagePath);
363        
364        String workspace = _workspaceSelector.getWorkspace();
365        String site = rootPage.getSiteName();
366        String contentType = getContentTypeId(rootPage);
367        _initializeCaches(rootPage, workspace, contentType);
368        Map<String, SortedSet<String>> transitionalPageCache = _transitionalPagesCache.get(workspace).get(contentType).get(site).get(rootPage.getSitemapName());
369        
370        return transitionalPageCache.containsKey(cachePagePath) ? transitionalPageCache.get(cachePagePath) : new TreeSet<>();
371    }
372    
373    private void _initializeCaches(Page rootPage, String workspace, String contentType)
374    {
375        String language = rootPage.getSitemapName();
376        String site = rootPage.getSiteName();
377        
378        // Get transitional page cache
379        Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>> transitionalPageCacheByContenttype = _transitionalPagesCache.computeIfAbsent(workspace, __ -> new HashMap<>());
380        Map<String, Map<String, SortedSet<String>>> transitionalPageCacheByLanguage = transitionalPageCacheByContenttype.computeIfAbsent(contentType, __ -> new HashMap<>()).computeIfAbsent(site, __ -> new HashMap<>());
381        
382        // Get user page cache
383        Map<String, Map<String, Map<String, Map<String, Map<String, String>>>>> userPageCacheByContenttype = _userPagesCache.computeIfAbsent(workspace, __ -> new HashMap<>()); 
384        Map<String, Map<String, Map<String, String>>> userPageCacheByLanguage = userPageCacheByContenttype.computeIfAbsent(contentType, __ -> new HashMap<>()).computeIfAbsent(site, __ -> new HashMap<>());
385        
386        if (transitionalPageCacheByLanguage.containsKey(language) && userPageCacheByLanguage.containsKey(language))
387        {
388            // Both caches are initialized
389            getLogger().debug("TransitionalPageCache and UserPageCache are initialized for workspace '{}' and content type '{}' and language '{}'", workspace, contentType, language);
390            return;
391        }
392        
393        // Get all contents which will appear in the sitemap
394        AmetysObjectIterable<Content> contents = getContentsForRootPage(rootPage);
395        
396        // Get their classification attribute value
397        Map<Content, String> transformedValuesByContent = new LinkedHashMap<>();
398        for (Content content : contents)
399        {
400            String value = getTransformedClassificationValue(rootPage, content);
401            if (value != null)
402            {
403                transformedValuesByContent.put(content, value);
404            }
405        }
406        
407        if (!transitionalPageCacheByLanguage.containsKey(language))
408        {
409            Map<String, SortedSet<String>> transitionalPageCache = new HashMap<>();
410            transitionalPageCacheByLanguage.put(language, transitionalPageCache);
411            
412            Set<String> transformedValues = new HashSet<>(transformedValuesByContent.values());
413            _buildTransitionalPageCache(transformedValues, transitionalPageCache);
414            getLogger().info("Transitional page cache was built for workspace '{}' and content type '{}' and language '{}'\n It is equal to: {}", workspace, contentType, language, transitionalPageCache);
415        }
416        
417        if (!userPageCacheByLanguage.containsKey(language))
418        {
419            Map<String, Map<String, String>> userPageCache = new HashMap<>();
420            userPageCacheByLanguage.put(language, userPageCache);
421            
422            int depth = getDepth(rootPage);
423            _buildUserPageCache(transformedValuesByContent, depth, userPageCache);
424            getLogger().info("User page cache was built for workspace '{}' and content type '{}' and language '{}'\n It is equal to: {}", workspace, contentType, language, userPageCache);
425        }
426    }
427    
428    private void _buildTransitionalPageCache(Set<String> transformedValues, Map<String, SortedSet<String>> transitionalPageCache)
429    {
430        for (String value : transformedValues)
431        {
432            char[] charArray = value.toCharArray();
433            for (int i = 0; i < charArray.length; i++)
434            {
435                String lastChar = String.valueOf(charArray[i]);
436                if (i == 0)
437                {
438                    // case _root
439                    if (!transitionalPageCache.containsKey("_root"))
440                    {
441                        transitionalPageCache.put("_root", new TreeSet<>());
442                    }
443                    Set<String> root = transitionalPageCache.get("_root");
444                    if (!root.contains(lastChar))
445                    {
446                        root.add(lastChar);
447                    }
448                }
449                else
450                {
451                    String currentPrefixWithoutLastChar = value.substring(0, i); // if value == "debu", equals to "deb"
452                    String currentPathWithoutLastChar = StringUtils.join(currentPrefixWithoutLastChar.toCharArray(), '/'); // if value == "debu", equals to "d/e/b"
453                    if (!transitionalPageCache.containsKey(currentPathWithoutLastChar))
454                    {
455                        transitionalPageCache.put(currentPathWithoutLastChar, new TreeSet<>());
456                    }
457                    Set<String> childPageNames = transitionalPageCache.get(currentPathWithoutLastChar);
458                    
459                    if (!childPageNames.contains(lastChar))
460                    {
461                        childPageNames.add(lastChar); // if value == "debu", add "u" in childPageNames for key "d/e/b"
462                    }
463                }
464            }
465        }
466    }
467    
468    private void _buildUserPageCache(Map<Content, String> transformedValuesByContent, int depth, Map<String, Map<String, String>> userPageCache)
469    {
470        if (depth == 0)
471        {
472            Map<String, String> rootContents = new LinkedHashMap<>();
473            for (Content content : transformedValuesByContent.keySet())
474            {
475                rootContents.put(content.getName(), content.getId());
476            }
477            userPageCache.put("_root", rootContents);
478            return;
479        }
480        
481        for (Content content : transformedValuesByContent.keySet())
482        {
483            String transformedValue = transformedValuesByContent.get(content);
484            for (int i = 0; i < depth; i++)
485            {
486                String currentPrefix = StringUtils.substring(transformedValue, 0, i + 1);
487                String currentPath = StringUtils.join(currentPrefix.toCharArray(), '/');
488                if (!userPageCache.containsKey(currentPath))
489                {
490                    userPageCache.put(currentPath, new LinkedHashMap<>());
491                }
492                Map<String, String> contentsForPath = userPageCache.get(currentPath);
493                
494                String contentName = content.getName();
495                if (!contentsForPath.containsKey(contentName))
496                {
497                    contentsForPath.put(contentName, content.getId());
498                }
499            }
500        }
501    }
502    
503    /**
504     * Get all user page child from page name
505     * @param rootPage the root page
506     * @param pagePath the page path
507     * @return all user page child from page name
508     */
509    public Map<String, String> getUserPagesContent(Page rootPage, String pagePath)
510    {
511        String cachePagePath = getName(pagePath);
512        
513        String workspace = _workspaceSelector.getWorkspace();
514        String site = rootPage.getSiteName();
515        String contentType = getContentTypeId(rootPage);
516        _initializeCaches(rootPage, workspace, contentType);
517        Map<String, Map<String, String>> userPageCache = _userPagesCache.get(workspace).get(contentType).get(site).get(rootPage.getSitemapName());
518        
519        return userPageCache.containsKey(cachePagePath) ? userPageCache.get(cachePagePath) : new HashMap<>();
520    }
521    
522    /**
523     * Get the user contents for a given root page
524     * @param rootPage the root page
525     * @return the user contents
526     */
527    public AmetysObjectIterable<Content> getContentsForRootPage(Page rootPage)
528    {
529        String contentType = getContentTypeId(rootPage);
530        String lang = rootPage.getSitemapName();
531        
532        Set<String> subTypes = _contentTypeEP.getSubTypes(contentType);
533        
534        List<Expression> contentTypeExpressions = new ArrayList<>();
535        contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, contentType));
536        for (String subType : subTypes)
537        {
538            contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, subType));
539        }
540        
541        Expression contentTypeExpression = new OrExpression(contentTypeExpressions.toArray(new Expression[subTypes.size() + 1]));
542        
543        Expression finalExpr = new AndExpression(contentTypeExpression, new LanguageExpression(Operator.EQ, lang));
544        
545        SortCriteria sort = new SortCriteria();
546        sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
547        
548        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort);
549        
550        return _resolver.query(xPathQuery);
551    }
552    
553    /**
554     * Gets name form path name
555     * @param pathName the path name
556     * @return the name
557     */
558    public String getName(String pathName)
559    {
560        String prefix = "page-";
561        String name = "";
562        for (String transitionalPageName : pathName.split("/"))
563        {
564            if (!name.equals(""))
565            {
566                name += "/";
567            }
568            name += StringUtils.startsWith(transitionalPageName, prefix) ? StringUtils.substringAfter(transitionalPageName, prefix) : transitionalPageName;
569        }
570        return name;
571    }
572    
573    /**
574     * Checks if name contains only Unicode digits and if so, prefix it with "page-"
575     * @param name The page name
576     * @return The potentially prefixed page name
577     */
578    public String getPathName(String name)
579    {
580        return StringUtils.isNumeric(name) ? "page-" + name : name; 
581    }
582    
583    /**
584     * Clear root page cache
585     * @param rootPage the root page
586     */
587    public void clearCache(Page rootPage)
588    {
589        clearCache(getContentTypeId(rootPage));
590    }
591    
592    /**
593     * Clear root page cache
594     * @param contentTypeId the content type id
595     */
596    public void clearCache(String contentTypeId)
597    {
598        // Clear cache for all workspaces
599        for (String workspaceName : _transitionalPagesCache.keySet())
600        {
601            Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>> contentTypeIdsMap = _transitionalPagesCache.get(workspaceName);
602            contentTypeIdsMap.remove(contentTypeId);
603        }
604        
605        for (String workspaceName : _userPagesCache.keySet())
606        {
607            Map<String, Map<String, Map<String, Map<String, Map<String, String>>>>> contentTypeIdsMap = _userPagesCache.get(workspaceName);
608            contentTypeIdsMap.remove(contentTypeId);
609        }
610        
611        _userDirectoryRootPages = new HashMap<>();
612    }
613}