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 attribute name for the content type of the user directory */ 073 public static final String CONTENT_TYPE_METADATA_NAME = "user-directory-root-contenttype"; 074 /** The metadata name for the classification metadata of the user directory */ 075 public static final String CLASSIFICATION_METADATA_METADATA_NAME = "user-directory-root-classification-metadata"; 076 /** The metadata name for the depth of the user directory */ 077 public static final String DEPTH_METADATA_NAME = "user-directory-root-depth"; 078 /** The parent content type id */ 079 public static final String ABSTRACT_USER_CONTENT_TYPE = "org.ametys.plugins.userdirectory.Content.user"; 080 081 /** The workspace selector. */ 082 protected WorkspaceSelector _workspaceSelector; 083 /** The ametys object resolver. */ 084 protected AmetysObjectResolver _resolver; 085 /** The extension point for content types */ 086 protected ContentTypeExtensionPoint _contentTypeEP; 087 088 /** The user directory root pages, indexed by workspace, site and sitemap name. */ 089 protected Map<String, Map<String, Map<String, Set<String>>>> _userDirectoryRootPages; 090 /** The list of transitional page workspace --> contentType --> (key : page name -- list<> children pages names) */ 091 protected Map<String, Map<String, Map<String, SortedSet<String>>>> _transitionalPagesCache; 092 /** The list of user page workspace --> contentType --> (key : page name --> Map(page name, content id)) */ 093 protected Map<String, Map<String, Map<String, Map<String, String>>>> _userPagesCache; 094 095 @Override 096 public void service(ServiceManager manager) throws ServiceException 097 { 098 _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE); 099 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 100 _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 101 } 102 103 @Override 104 public void initialize() throws Exception 105 { 106 _userDirectoryRootPages = new HashMap<>(); 107 _transitionalPagesCache = new HashMap<>(); 108 _userPagesCache = new HashMap<>(); 109 } 110 111 /** 112 * Gets the user directory root pages from the given content type id, whatever the site. 113 * @param contentTypeId The content type id 114 * @return the user directory root pages. 115 * @throws AmetysRepositoryException if an error occured. 116 */ 117 public Set<Page> getUserDirectoryRootPages(String contentTypeId) throws AmetysRepositoryException 118 { 119 Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName()); 120 Expression contentTypeExp = new StringExpression(CONTENT_TYPE_METADATA_NAME, Operator.EQ, contentTypeId); 121 122 AndExpression andExp = new AndExpression(expression, contentTypeExp); 123 124 String query = PageQueryHelper.getPageXPathQuery(null, null, null, andExp, null); 125 126 AmetysObjectIterable<Page> pages = _resolver.query(query); 127 128 return pages.stream().collect(Collectors.toSet()); 129 } 130 131 /** 132 * Gets the user directory root page of a specific content type. 133 * @param siteName The site name 134 * @param sitemapName The sitemap 135 * @param contentTypeId The content type id 136 * @return the user directory root pages. 137 * @throws AmetysRepositoryException if an error occured. 138 */ 139 public Page getUserDirectoryRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException 140 { 141 String contentTypeIdToCompare = contentTypeId != null ? contentTypeId : ""; 142 143 for (Page userDirectoryRootPage : getUserDirectoryRootPages(siteName, sitemapName)) 144 { 145 if (contentTypeIdToCompare.equals(getContentTypeId(userDirectoryRootPage))) 146 { 147 return userDirectoryRootPage; 148 } 149 } 150 151 return null; 152 } 153 154 /** 155 * Gets the user directory root pages. 156 * @param siteName The site name 157 * @param sitemapName The sitemap 158 * @return the user directory root pages. 159 * @throws AmetysRepositoryException if an error occured. 160 */ 161 public Set<Page> getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException 162 { 163 Set<Page> rootPages = new HashSet<>(); 164 165 String workspace = _workspaceSelector.getWorkspace(); 166 167 Map<String, Map<String, Set<String>>> wspUserDirectoryRootPages; 168 if (_userDirectoryRootPages.containsKey(workspace)) 169 { 170 wspUserDirectoryRootPages = _userDirectoryRootPages.get(workspace); 171 } 172 else 173 { 174 wspUserDirectoryRootPages = new HashMap<>(); 175 _userDirectoryRootPages.put(workspace, wspUserDirectoryRootPages); 176 } 177 178 Map<String, Set<String>> siteUserDirectoryRootPages; 179 if (wspUserDirectoryRootPages.containsKey(siteName)) 180 { 181 siteUserDirectoryRootPages = wspUserDirectoryRootPages.get(siteName); 182 } 183 else 184 { 185 siteUserDirectoryRootPages = new HashMap<>(); 186 wspUserDirectoryRootPages.put(siteName, siteUserDirectoryRootPages); 187 } 188 189 if (siteUserDirectoryRootPages.containsKey(sitemapName)) 190 { 191 Set<String> userDirectoryRootPageIds = siteUserDirectoryRootPages.get(sitemapName); 192 if (userDirectoryRootPageIds != null) 193 { 194 rootPages = userDirectoryRootPageIds.stream() 195 .map(this::_resolvePage) 196 .filter(Objects::nonNull) 197 .collect(Collectors.toSet()); 198 } 199 } 200 else 201 { 202 rootPages = _getUserDirectoryRootPages(siteName, sitemapName); 203 Set<String> userDirectoryRootPageIds = rootPages.stream() 204 .map(Page::getId) 205 .collect(Collectors.toSet()); 206 siteUserDirectoryRootPages.put(sitemapName, userDirectoryRootPageIds); 207 } 208 209 return rootPages; 210 } 211 212 private Page _resolvePage(String pageId) 213 { 214 try 215 { 216 return _resolver.resolveById(pageId); 217 } 218 catch (UnknownAmetysObjectException e) 219 { 220 // The page stored in cache may be deleted 221 return null; 222 } 223 } 224 225 /** 226 * Get the user directory root pages, without searching in the cache. 227 * @param siteName the current site. 228 * @param sitemapName the sitemap name. 229 * @return the user directory root pages 230 * @throws AmetysRepositoryException if an error occured. 231 */ 232 protected Set<Page> _getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException 233 { 234 Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName()); 235 236 String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null); 237 238 AmetysObjectIterable<Page> pages = _resolver.query(query); 239 240 return pages.stream().collect(Collectors.toSet()); 241 } 242 243 /** 244 * Gets the depth of the user directory root page 245 * @param rootPage The user directory root page 246 * @return the depth of the user directory root page 247 */ 248 public int getDepth(Page rootPage) 249 { 250 return Math.toIntExact(rootPage.getValue(DEPTH_METADATA_NAME)); 251 } 252 253 /** 254 * Gets the name of the classification metadata 255 * @param rootPage The user directory root page 256 * @return the name of the classification metadata 257 */ 258 public String getClassificationAttribute(Page rootPage) 259 { 260 return rootPage.getValue(CLASSIFICATION_METADATA_METADATA_NAME); 261 } 262 263 /** 264 * Gets the content type id 265 * @param rootPage The user directory root page 266 * @return the content type id 267 */ 268 public String getContentTypeId(Page rootPage) 269 { 270 return rootPage.getValue(CONTENT_TYPE_METADATA_NAME); 271 } 272 273 /** 274 * Gets the content type 275 * @param rootPage The user directory root page 276 * @return the content type 277 */ 278 public ContentType getContentType(Page rootPage) 279 { 280 String contentTypeId = getContentTypeId(rootPage); 281 return StringUtils.isNotBlank(contentTypeId) ? _contentTypeEP.getExtension(contentTypeId) : null; 282 } 283 284 /** 285 * Gets the value of the classification attribute for the given content, transformed for building tree hierarchy 286 * <br>The transformation takes the lower-case of all characters, removes non-alphanumeric characters, 287 * and takes the first characters to not have a string with a size bigger than the depth 288 * <br>For instance, if the value for the content is "Aéa Foo-bar" and the depth is 7, 289 * then this method will return "aeafoob" 290 * @param rootPage The user directory root page 291 * @param content The content 292 * @return the transformed value of the classification attribute for the given content. Can be null 293 */ 294 public String getTransformedClassificationValue(Page rootPage, Content content) 295 { 296 String attribute = getClassificationAttribute(rootPage); 297 int depth = getDepth(rootPage); 298 299 // 1) get value of the classification attribute 300 String classification = content.getValue(attribute); 301 302 if (classification == null) 303 { 304 // The classification does not exists for the content 305 getLogger().info("The classification attribute '{}' does not exist for the content {}", attribute, content); 306 return null; 307 } 308 309 try 310 { 311 // 2) replace special character 312 // 3) remove '-' characters 313 314 // FIXME CMS-5758 FilterNameHelper.filterName do not authorized name with numbers only. 315 // So code of FilterNamehelper is temporarily duplicated here with a slightly modified RegExp 316// String transformedValue = FilterNameHelper.filterName(classification).replace("-", ""); 317 String transformedValue = _filterName(classification).replace("-", ""); 318 319 // 4) only keep 'depth' first characters (if depth = 3, "de" becomes "de", "debu" becomes "deb", etc.) 320 return StringUtils.substring(transformedValue, 0, depth); 321 } 322 catch (IllegalArgumentException e) 323 { 324 // The value of the classification attribute is not valid 325 getLogger().warn("The classification attribute '{}' does not have a valid value ({}) for the content {}", attribute, classification, content); 326 return null; 327 } 328 } 329 330 private String _filterName(String name) 331 { 332 Pattern pattern = Pattern.compile("^()[0-9-_]*[a-z0-9].*$"); 333 // Use lower case 334 // then remove accents 335 // then replace contiguous spaces with one dash 336 // and finally remove non-alphanumeric characters except - 337 String filteredName = Normalizer.normalize(name.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 338 filteredName = filteredName.replaceAll("œ", "oe").replaceAll("æ", "ae").replaceAll(" +", "-").replaceAll("[^\\w-]", "-").replaceAll("-+", "-"); 339 340 Matcher m = pattern.matcher(filteredName); 341 if (!m.matches()) 342 { 343 throw new IllegalArgumentException(filteredName + " doesn't match the expected regular expression : " + pattern.pattern()); 344 } 345 346 filteredName = filteredName.substring(m.end(1)); 347 348 // Remove characters '-' and '_' at the start and the end of the string 349 return StringUtils.strip(filteredName, "-_"); 350 } 351 352 /** 353 * Get all transitional page child from page name 354 * @param rootPage the root page 355 * @param pagePath the page path 356 * @return all transitional page child from page name 357 */ 358 public SortedSet<String> getTransitionalPagesName(Page rootPage, String pagePath) 359 { 360 String cachePagePath = getName(pagePath); 361 362 String workspace = _workspaceSelector.getWorkspace(); 363 String contentType = getContentTypeId(rootPage); 364 _initializeCaches(rootPage, workspace, contentType); 365 Map<String, SortedSet<String>> transitionalPageCache = _transitionalPagesCache.get(workspace).get(contentType); 366 367 return transitionalPageCache.containsKey(cachePagePath) ? transitionalPageCache.get(cachePagePath) : new TreeSet<>(); 368 } 369 370 private void _initializeCaches(Page rootPage, String workspace, String contentType) 371 { 372 // Get transitional page cache 373 if (!_transitionalPagesCache.containsKey(workspace)) 374 { 375 _transitionalPagesCache.put(workspace, new HashMap<>()); 376 } 377 Map<String, Map<String, SortedSet<String>>> transitionalPageCacheByContenttype = _transitionalPagesCache.get(workspace); 378 379 // Get user page cache 380 if (!_userPagesCache.containsKey(workspace)) 381 { 382 _userPagesCache.put(workspace, new HashMap<>()); 383 } 384 Map<String, Map<String, Map<String, String>>> userPageCacheByContenttype = _userPagesCache.get(workspace); 385 386 if (transitionalPageCacheByContenttype.containsKey(contentType) && userPageCacheByContenttype.containsKey(contentType)) 387 { 388 // Both caches are initialized 389 getLogger().debug("TransitionalPageCache and UserPageCache are initialized for workspace '{}' and content type '{}'", workspace, contentType); 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 (!transitionalPageCacheByContenttype.containsKey(contentType)) 408 { 409 Map<String, SortedSet<String>> transitionalPageCache = new HashMap<>(); 410 transitionalPageCacheByContenttype.put(contentType, 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 '{}'\n It is equal to: {}", workspace, contentType, transitionalPageCache); 415 } 416 417 if (!userPageCacheByContenttype.containsKey(contentType)) 418 { 419 Map<String, Map<String, String>> userPageCache = new HashMap<>(); 420 userPageCacheByContenttype.put(contentType, 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 '{}'\n It is equal to: {}", workspace, contentType, 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 contentType = getContentTypeId(rootPage); 515 _initializeCaches(rootPage, workspace, contentType); 516 Map<String, Map<String, String>> userPageCache = _userPagesCache.get(workspace).get(contentType); 517 518 return userPageCache.containsKey(cachePagePath) ? userPageCache.get(cachePagePath) : new HashMap<>(); 519 } 520 521 private AmetysObjectIterable<Content> _getContentsForRootPage(Page rootPage) 522 { 523 String contentType = getContentTypeId(rootPage); 524 String lang = rootPage.getSitemapName(); 525 526 Set<String> subTypes = _contentTypeEP.getSubTypes(contentType); 527 528 List<Expression> contentTypeExpressions = new ArrayList<>(); 529 contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, contentType)); 530 for (String subType : subTypes) 531 { 532 contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, subType)); 533 } 534 535 Expression contentTypeExpression = new OrExpression(contentTypeExpressions.toArray(new Expression[subTypes.size() + 1])); 536 537 Expression finalExpr = new AndExpression(contentTypeExpression, new LanguageExpression(Operator.EQ, lang)); 538 539 SortCriteria sort = new SortCriteria(); 540 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 541 542 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 543 544 return _resolver.query(xPathQuery); 545 } 546 547 /** 548 * Gets name form path name 549 * @param pathName the path name 550 * @return the name 551 */ 552 public String getName(String pathName) 553 { 554 String prefix = "page-"; 555 String name = ""; 556 for (String transitionalPageName : pathName.split("/")) 557 { 558 if (!name.equals("")) 559 { 560 name += "/"; 561 } 562 name += StringUtils.startsWith(transitionalPageName, prefix) ? StringUtils.substringAfter(transitionalPageName, prefix) : transitionalPageName; 563 } 564 return name; 565 } 566 567 /** 568 * Checks if name contains only Unicode digits and if so, prefix it with "page-" 569 * @param name The page name 570 * @return The potentially prefixed page name 571 */ 572 public String getPathName(String name) 573 { 574 return StringUtils.isNumeric(name) ? "page-" + name : name; 575 } 576 577 /** 578 * Clear root page cache 579 * @param rootPage the root page 580 */ 581 public void clearCache(Page rootPage) 582 { 583 clearCache(getContentTypeId(rootPage)); 584 } 585 586 /** 587 * Clear root page cache 588 * @param contentTypeId the content type id 589 */ 590 public void clearCache(String contentTypeId) 591 { 592 // Clear cache for all workspaces 593 for (String workspaceName : _transitionalPagesCache.keySet()) 594 { 595 Map<String, Map<String, SortedSet<String>>> contentTypeIdsMap = _transitionalPagesCache.get(workspaceName); 596 contentTypeIdsMap.remove(contentTypeId); 597 } 598 599 for (String workspaceName : _userPagesCache.keySet()) 600 { 601 Map<String, Map<String, Map<String, String>>> contentTypeIdsMap = _userPagesCache.get(workspaceName); 602 contentTypeIdsMap.remove(contentTypeId); 603 } 604 605 _userDirectoryRootPages = new HashMap<>(); 606 } 607}