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 --> contentType --> site --> language --> (key : page name -- list<> 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 --> contentType --> site --> language --> (key : page name --> 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}