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 --> contentType --> (key : page name -- list<> children pages names) */ 093 protected Map<String, Map<String, Map<String, SortedSet<String>>>> _transitionalPagesCache; 094 /** The list of user page workspace --> contentType --> (key : page name --> 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}