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 --> site --> language --> (key : page name -- list<> children pages names) */ 091 protected Map<String, Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>>> _transitionalPagesCache; 092 /** The list of user page workspace --> contentType --> site --> language --> (key : page name --> Map(page name, content id)) */ 093 protected Map<String, Map<String, 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 have been 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 site = rootPage.getSiteName(); 364 String contentType = getContentTypeId(rootPage); 365 _initializeCaches(rootPage, workspace, contentType); 366 Map<String, SortedSet<String>> transitionalPageCache = _transitionalPagesCache.get(workspace).get(contentType).get(site).get(rootPage.getSitemapName()); 367 368 return transitionalPageCache.containsKey(cachePagePath) ? transitionalPageCache.get(cachePagePath) : new TreeSet<>(); 369 } 370 371 private void _initializeCaches(Page rootPage, String workspace, String contentType) 372 { 373 String language = rootPage.getSitemapName(); 374 String site = rootPage.getSiteName(); 375 376 // Get transitional page cache 377 Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>> transitionalPageCacheByContenttype = _transitionalPagesCache.computeIfAbsent(workspace, __ -> new HashMap<>()); 378 Map<String, Map<String, SortedSet<String>>> transitionalPageCacheByLanguage = transitionalPageCacheByContenttype.computeIfAbsent(contentType, __ -> new HashMap<>()).computeIfAbsent(site, __ -> new HashMap<>()); 379 380 // Get user page cache 381 Map<String, Map<String, Map<String, Map<String, Map<String, String>>>>> userPageCacheByContenttype = _userPagesCache.computeIfAbsent(workspace, __ -> new HashMap<>()); 382 Map<String, Map<String, Map<String, String>>> userPageCacheByLanguage = userPageCacheByContenttype.computeIfAbsent(contentType, __ -> new HashMap<>()).computeIfAbsent(site, __ -> new HashMap<>()); 383 384 if (transitionalPageCacheByLanguage.containsKey(language) && userPageCacheByLanguage.containsKey(language)) 385 { 386 // Both caches are initialized 387 getLogger().debug("TransitionalPageCache and UserPageCache are initialized for workspace '{}' and content type '{}' and language '{}'", workspace, contentType, language); 388 return; 389 } 390 391 // Get all contents which will appear in the sitemap 392 AmetysObjectIterable<Content> contents = _getContentsForRootPage(rootPage); 393 394 // Get their classification attribute value 395 Map<Content, String> transformedValuesByContent = new LinkedHashMap<>(); 396 for (Content content : contents) 397 { 398 String value = getTransformedClassificationValue(rootPage, content); 399 if (value != null) 400 { 401 transformedValuesByContent.put(content, value); 402 } 403 } 404 405 if (!transitionalPageCacheByLanguage.containsKey(language)) 406 { 407 Map<String, SortedSet<String>> transitionalPageCache = new HashMap<>(); 408 transitionalPageCacheByLanguage.put(language, transitionalPageCache); 409 410 Set<String> transformedValues = new HashSet<>(transformedValuesByContent.values()); 411 _buildTransitionalPageCache(transformedValues, transitionalPageCache); 412 getLogger().info("Transitional page cache was built for workspace '{}' and content type '{}' and language '{}'\n It is equal to: {}", workspace, contentType, language, transitionalPageCache); 413 } 414 415 if (!userPageCacheByLanguage.containsKey(language)) 416 { 417 Map<String, Map<String, String>> userPageCache = new HashMap<>(); 418 userPageCacheByLanguage.put(language, userPageCache); 419 420 int depth = getDepth(rootPage); 421 _buildUserPageCache(transformedValuesByContent, depth, userPageCache); 422 getLogger().info("User page cache was built for workspace '{}' and content type '{}' and language '{}'\n It is equal to: {}", workspace, contentType, language, userPageCache); 423 } 424 } 425 426 private void _buildTransitionalPageCache(Set<String> transformedValues, Map<String, SortedSet<String>> transitionalPageCache) 427 { 428 for (String value : transformedValues) 429 { 430 char[] charArray = value.toCharArray(); 431 for (int i = 0; i < charArray.length; i++) 432 { 433 String lastChar = String.valueOf(charArray[i]); 434 if (i == 0) 435 { 436 // case _root 437 if (!transitionalPageCache.containsKey("_root")) 438 { 439 transitionalPageCache.put("_root", new TreeSet<>()); 440 } 441 Set<String> root = transitionalPageCache.get("_root"); 442 if (!root.contains(lastChar)) 443 { 444 root.add(lastChar); 445 } 446 } 447 else 448 { 449 String currentPrefixWithoutLastChar = value.substring(0, i); // if value == "debu", equals to "deb" 450 String currentPathWithoutLastChar = StringUtils.join(currentPrefixWithoutLastChar.toCharArray(), '/'); // if value == "debu", equals to "d/e/b" 451 if (!transitionalPageCache.containsKey(currentPathWithoutLastChar)) 452 { 453 transitionalPageCache.put(currentPathWithoutLastChar, new TreeSet<>()); 454 } 455 Set<String> childPageNames = transitionalPageCache.get(currentPathWithoutLastChar); 456 457 if (!childPageNames.contains(lastChar)) 458 { 459 childPageNames.add(lastChar); // if value == "debu", add "u" in childPageNames for key "d/e/b" 460 } 461 } 462 } 463 } 464 } 465 466 private void _buildUserPageCache(Map<Content, String> transformedValuesByContent, int depth, Map<String, Map<String, String>> userPageCache) 467 { 468 if (depth == 0) 469 { 470 Map<String, String> rootContents = new LinkedHashMap<>(); 471 for (Content content : transformedValuesByContent.keySet()) 472 { 473 rootContents.put(content.getName(), content.getId()); 474 } 475 userPageCache.put("_root", rootContents); 476 return; 477 } 478 479 for (Content content : transformedValuesByContent.keySet()) 480 { 481 String transformedValue = transformedValuesByContent.get(content); 482 for (int i = 0; i < depth; i++) 483 { 484 String currentPrefix = StringUtils.substring(transformedValue, 0, i + 1); 485 String currentPath = StringUtils.join(currentPrefix.toCharArray(), '/'); 486 if (!userPageCache.containsKey(currentPath)) 487 { 488 userPageCache.put(currentPath, new LinkedHashMap<>()); 489 } 490 Map<String, String> contentsForPath = userPageCache.get(currentPath); 491 492 String contentName = content.getName(); 493 if (!contentsForPath.containsKey(contentName)) 494 { 495 contentsForPath.put(contentName, content.getId()); 496 } 497 } 498 } 499 } 500 501 /** 502 * Get all user page child from page name 503 * @param rootPage the root page 504 * @param pagePath the page path 505 * @return all user page child from page name 506 */ 507 public Map<String, String> getUserPagesContent(Page rootPage, String pagePath) 508 { 509 String cachePagePath = getName(pagePath); 510 511 String workspace = _workspaceSelector.getWorkspace(); 512 String site = rootPage.getSiteName(); 513 String contentType = getContentTypeId(rootPage); 514 _initializeCaches(rootPage, workspace, contentType); 515 Map<String, Map<String, String>> userPageCache = _userPagesCache.get(workspace).get(contentType).get(site).get(rootPage.getSitemapName()); 516 517 return userPageCache.containsKey(cachePagePath) ? userPageCache.get(cachePagePath) : new HashMap<>(); 518 } 519 520 private AmetysObjectIterable<Content> _getContentsForRootPage(Page rootPage) 521 { 522 String contentType = getContentTypeId(rootPage); 523 String lang = rootPage.getSitemapName(); 524 525 Set<String> subTypes = _contentTypeEP.getSubTypes(contentType); 526 527 List<Expression> contentTypeExpressions = new ArrayList<>(); 528 contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, contentType)); 529 for (String subType : subTypes) 530 { 531 contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, subType)); 532 } 533 534 Expression contentTypeExpression = new OrExpression(contentTypeExpressions.toArray(new Expression[subTypes.size() + 1])); 535 536 Expression finalExpr = new AndExpression(contentTypeExpression, new LanguageExpression(Operator.EQ, lang)); 537 538 SortCriteria sort = new SortCriteria(); 539 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 540 541 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 542 543 return _resolver.query(xPathQuery); 544 } 545 546 /** 547 * Gets name form path name 548 * @param pathName the path name 549 * @return the name 550 */ 551 public String getName(String pathName) 552 { 553 String prefix = "page-"; 554 String name = ""; 555 for (String transitionalPageName : pathName.split("/")) 556 { 557 if (!name.equals("")) 558 { 559 name += "/"; 560 } 561 name += StringUtils.startsWith(transitionalPageName, prefix) ? StringUtils.substringAfter(transitionalPageName, prefix) : transitionalPageName; 562 } 563 return name; 564 } 565 566 /** 567 * Checks if name contains only Unicode digits and if so, prefix it with "page-" 568 * @param name The page name 569 * @return The potentially prefixed page name 570 */ 571 public String getPathName(String name) 572 { 573 return StringUtils.isNumeric(name) ? "page-" + name : name; 574 } 575 576 /** 577 * Clear root page cache 578 * @param rootPage the root page 579 */ 580 public void clearCache(Page rootPage) 581 { 582 clearCache(getContentTypeId(rootPage)); 583 } 584 585 /** 586 * Clear root page cache 587 * @param contentTypeId the content type id 588 */ 589 public void clearCache(String contentTypeId) 590 { 591 // Clear cache for all workspaces 592 for (String workspaceName : _transitionalPagesCache.keySet()) 593 { 594 Map<String, Map<String, Map<String, Map<String, SortedSet<String>>>>> contentTypeIdsMap = _transitionalPagesCache.get(workspaceName); 595 contentTypeIdsMap.remove(contentTypeId); 596 } 597 598 for (String workspaceName : _userPagesCache.keySet()) 599 { 600 Map<String, Map<String, Map<String, Map<String, Map<String, String>>>>> contentTypeIdsMap = _userPagesCache.get(workspaceName); 601 contentTypeIdsMap.remove(contentTypeId); 602 } 603 604 _userDirectoryRootPages = new HashMap<>(); 605 } 606}