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