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