001/* 002 * Copyright 2018 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.ugc.page; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Comparator; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Optional; 025import java.util.Set; 026import java.util.stream.Collectors; 027 028import javax.jcr.Node; 029import javax.jcr.RepositoryException; 030import javax.jcr.Value; 031 032import org.apache.avalon.framework.component.Component; 033import org.apache.avalon.framework.context.Context; 034import org.apache.avalon.framework.context.ContextException; 035import org.apache.avalon.framework.context.Contextualizable; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.avalon.framework.service.Serviceable; 039import org.apache.cocoon.components.ContextHelper; 040import org.apache.cocoon.environment.Request; 041import org.apache.commons.lang.StringUtils; 042import org.apache.jackrabbit.value.StringValue; 043import org.slf4j.Logger; 044 045import org.ametys.cms.contenttype.ContentAttributeDefinition; 046import org.ametys.cms.contenttype.ContentType; 047import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 048import org.ametys.cms.contenttype.ContentTypesHelper; 049import org.ametys.cms.data.ContentValue; 050import org.ametys.cms.repository.Content; 051import org.ametys.cms.repository.ContentTypeExpression; 052import org.ametys.cms.repository.LanguageExpression; 053import org.ametys.core.observation.Event; 054import org.ametys.core.observation.ObservationManager; 055import org.ametys.core.user.CurrentUserProvider; 056import org.ametys.core.util.I18nUtils; 057import org.ametys.core.util.LambdaUtils; 058import org.ametys.plugins.repository.AmetysObjectIterable; 059import org.ametys.plugins.repository.AmetysObjectResolver; 060import org.ametys.plugins.repository.AmetysRepositoryException; 061import org.ametys.plugins.repository.UnknownAmetysObjectException; 062import org.ametys.plugins.repository.jcr.JCRAmetysObject; 063import org.ametys.plugins.repository.query.QueryHelper; 064import org.ametys.plugins.repository.query.SortCriteria; 065import org.ametys.plugins.repository.query.expression.AndExpression; 066import org.ametys.plugins.repository.query.expression.Expression; 067import org.ametys.plugins.repository.query.expression.Expression.Operator; 068import org.ametys.plugins.ugc.observation.ObservationConstants; 069import org.ametys.plugins.repository.query.expression.MetadataExpression; 070import org.ametys.plugins.repository.query.expression.NotExpression; 071import org.ametys.plugins.repository.query.expression.OrExpression; 072import org.ametys.plugins.repository.query.expression.StringExpression; 073import org.ametys.plugins.repository.query.expression.VirtualFactoryExpression; 074import org.ametys.runtime.model.ElementDefinition; 075import org.ametys.runtime.model.Enumerator; 076import org.ametys.runtime.model.ModelItem; 077import org.ametys.runtime.plugin.component.AbstractLogEnabled; 078import org.ametys.web.WebConstants; 079import org.ametys.web.WebHelper; 080import org.ametys.web.repository.SiteAwareAmetysObject; 081import org.ametys.web.repository.page.ModifiablePage; 082import org.ametys.web.repository.page.Page; 083import org.ametys.web.repository.page.PageQueryHelper; 084import org.ametys.web.repository.page.jcr.DefaultPage; 085 086/** 087 * Component providing methods to retrieve ugc virtual pages, such as the ugc root, 088 * transitional page and ugc content page. 089 */ 090public class UGCPageHandler extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 091{ 092 /** The attribute to get the name of transitional page */ 093 public static final String ATTRIBUTE_TRANSITIONAL_PAGE_METADATA_VALUE = "metadata_value"; 094 095 /** The attribute to get the title of transitional page */ 096 public static final String ATTRIBUTE_TRANSITIONAL_PAGE_TITLE = "title"; 097 098 /** The avalon role. */ 099 public static final String ROLE = UGCPageHandler.class.getName(); 100 101 /** The data name for the content type of the ugc */ 102 public static final String CONTENT_TYPE_DATA_NAME = "ugc-root-contenttype"; 103 104 /** The data name for the classification attribute of the ugc */ 105 public static final String CLASSIFICATION_ATTRIBUTE_DATA_NAME = "ugc-root-classification-metadata"; 106 107 /** The data name for the visibility of transitional page of the ugc */ 108 public static final String CLASSIFICATION_PAGE_VISIBLE_DATA_NAME = "ugc-root-classification-page-visible"; 109 110 /** The ametys object resolver */ 111 protected AmetysObjectResolver _resolver; 112 113 /** The content type extension point */ 114 protected ContentTypeExtensionPoint _cTypeEP; 115 116 /** The content types helper */ 117 protected ContentTypesHelper _cTypeHelper; 118 119 /** The i18n utils */ 120 protected I18nUtils _i18nUtils; 121 122 /** The avalon context */ 123 protected Context _context; 124 125 /** Observer manager. */ 126 protected ObservationManager _observationManager; 127 128 /** Current user provider */ 129 protected CurrentUserProvider _currentUserProvider; 130 131 @Override 132 public void service(ServiceManager manager) throws ServiceException 133 { 134 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 135 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 136 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 137 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 138 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 139 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 140 } 141 142 public void contextualize(Context context) throws ContextException 143 { 144 _context = context; 145 } 146 147 @Override 148 protected Logger getLogger() 149 { 150 return super.getLogger(); 151 } 152 153 /** 154 * Gets the path of the classification attribute 155 * @param rootPage The ugc root page 156 * @return the path of the classification attribute 157 */ 158 public String getClassificationAttribute(Page rootPage) 159 { 160 return rootPage.getValue(CLASSIFICATION_ATTRIBUTE_DATA_NAME); 161 } 162 163 /** 164 * Gets the content type id 165 * @param rootPage The ugc root page 166 * @return the content type id 167 */ 168 public String getContentTypeId(Page rootPage) 169 { 170 return rootPage.getValue(CONTENT_TYPE_DATA_NAME); 171 } 172 173 /** 174 * <code>true</code> if the classification pages are visible 175 * @param rootPage The ugc root page 176 * @return <code>true</code> if the classification pages are visible 177 */ 178 public boolean isClassificationPagesVisible(Page rootPage) 179 { 180 return rootPage.getValue(CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, false); 181 } 182 183 /** 184 * True if the page a UGC root page for the given content type 185 * @param page the page 186 * @param contentTypeId The id of content type. Cannot be null. 187 * @return true if the page is a UGC root page for the given content type 188 */ 189 public boolean isUGCRootPage(DefaultPage page, String contentTypeId) 190 { 191 try 192 { 193 Node node = page.getNode(); 194 195 if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY)) 196 { 197 List<Value> values = Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues()); 198 199 boolean isUGCRootPage = values.stream() 200 .map(LambdaUtils.wrap(Value::getString)) 201 .anyMatch(v -> VirtualUGCPageFactory.class.getName().equals(v)); 202 203 if (isUGCRootPage) 204 { 205 return contentTypeId.equals(page.getValue(CONTENT_TYPE_DATA_NAME)); 206 } 207 } 208 } 209 catch (RepositoryException e) 210 { 211 getLogger().warn("Unable to determine if page '" + page.getId() + "' is a UGC root page", e); 212 } 213 214 return false; 215 } 216 217 /** 218 * Gets the ugc root pages from the given content type id. 219 * @param siteName the site name 220 * @param sitemapName the sitemap name 221 * @param contentTypeId The content type id 222 * @return the ugc root page. 223 * @throws AmetysRepositoryException if an error occured. 224 */ 225 public Page getUGCRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException 226 { 227 Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName()); 228 Expression contentTypeExp = new StringExpression(CONTENT_TYPE_DATA_NAME, Operator.EQ, contentTypeId); 229 230 AndExpression andExp = new AndExpression(expression, contentTypeExp); 231 232 String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, andExp, null); 233 234 AmetysObjectIterable<Page> pages = _resolver.query(query); 235 236 return pages.iterator().hasNext() ? pages.iterator().next() : null; 237 } 238 239 /** 240 * Get the ugc root pages 241 * @param siteName the current site. 242 * @param sitemapName the sitemap name. 243 * @return the ugc root pages 244 * @throws AmetysRepositoryException if an error occured. 245 */ 246 public Set<Page> getUGCRootPages(String siteName, String sitemapName) throws AmetysRepositoryException 247 { 248 Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName()); 249 250 String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null); 251 252 AmetysObjectIterable<Page> pages = _resolver.query(query); 253 254 return pages.stream().collect(Collectors.toSet()); 255 } 256 257 /** 258 * Set the ugc root page 259 * @param page the page to set as root 260 * @param contentTypeId the type of content root 261 * @param attributePath path to classification attribute 262 * @param classificationPageVisible true to show classification 263 * @throws RepositoryException if a repository error occurred 264 */ 265 public void setUGCRoot(Page page, String contentTypeId, String attributePath, boolean classificationPageVisible) throws RepositoryException 266 { 267 Page currentUGCPage = getUGCRootPage(page.getSiteName(), page.getSitemapName(), contentTypeId); 268 269 Map<String, Object> eventParams = new HashMap<>(); 270 eventParams.put(org.ametys.web.ObservationConstants.ARGS_PAGE, page); 271 272 if (currentUGCPage != null && currentUGCPage.getId().equals(page.getId())) 273 { 274 // Unindex pages for all workspaces before the properties changed 275 _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATING, _currentUserProvider.getUser(), eventParams)); 276 277 _updateUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible); 278 } 279 else 280 { 281 _addUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible); 282 } 283 284 // Live synchronization 285 _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams)); 286 287 // Indexation 288 _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATED, _currentUserProvider.getUser(), eventParams)); 289 } 290 291 private void _addUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible) throws RepositoryException 292 { 293 if (page instanceof JCRAmetysObject) 294 { 295 JCRAmetysObject jcrPage = (JCRAmetysObject) page; 296 Node node = jcrPage.getNode(); 297 298 List<Value> values = new ArrayList<>(); 299 if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY)) 300 { 301 values.addAll(Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues())); 302 } 303 304 StringValue virtualUGCPageFactoryClassName = new StringValue(VirtualUGCPageFactory.class.getName()); 305 if (!values.contains(virtualUGCPageFactoryClassName)) 306 { 307 values.add(virtualUGCPageFactoryClassName); 308 } 309 310 node.setProperty(AmetysObjectResolver.VIRTUAL_PROPERTY, values.toArray(new Value[values.size()])); 311 312 // Set the ugc root property 313 if (page instanceof ModifiablePage) 314 { 315 ((ModifiablePage) page).setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType); 316 ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata); 317 ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible); 318 } 319 320 jcrPage.saveChanges(); 321 } 322 } 323 324 private void _updateUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible) 325 { 326 if (page instanceof ModifiablePage) 327 { 328 ModifiablePage modifiablePage = (ModifiablePage) page; 329 330 // Set the ugc root property 331 modifiablePage.setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType); 332 modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata); 333 modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible); 334 335 modifiablePage.saveChanges(); 336 } 337 } 338 339 /** 340 * Get UGC contents from rootPage 341 * @param rootPage the root page 342 * @return the list of UGC contents 343 */ 344 public AmetysObjectIterable<Content> getContentsForRootPage(Page rootPage) 345 { 346 String lang = rootPage.getSitemapName(); 347 String contentType = getContentTypeId(rootPage); 348 349 ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType); 350 351 StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName()); 352 Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE)); 353 Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr); 354 355 Expression finalExpr = new AndExpression(contentTypeExp, new LanguageExpression(Operator.EQ, lang), fullSiteExpr); 356 357 SortCriteria sort = new SortCriteria(); 358 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 359 360 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 361 362 return _resolver.query(xPathQuery); 363 } 364 365 /** 366 * Determines if content is part of UGC root page 367 * @param rootPage The root page 368 * @param content the content 369 * @return true if content is part of UGC root page 370 */ 371 public boolean hasContentForRootPage(Page rootPage, Content content) 372 { 373 String contentType = getContentTypeId(rootPage); 374 String siteName = rootPage.getSiteName(); 375 String classificationMetadata = getClassificationAttribute(rootPage); 376 377 return _cTypeHelper.isInstanceOf(content, contentType) // match content type 378 && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site 379 && StringUtils.isBlank(classificationMetadata); // no classification attribute 380 } 381 382 /** 383 * Get the map of transitional page (name : (id, title)) 384 * @param rootPage the root page 385 * @return The map of transitional page 386 */ 387 public Map<String, Map<String, String>> getTransitionalPage(Page rootPage) 388 { 389 return _getClassificationType(rootPage) 390 .allTransitionalPages() 391 .stream() 392 .sorted(Comparator.comparing(TransitionalPageInformation::getTitle)) 393 .collect(LambdaUtils.Collectors.toLinkedHashMap( 394 TransitionalPageInformation::getKey, 395 TransitionalPageInformation::getInfo)); 396 } 397 398 private ClassificationType _getClassificationType(Page rootPage) 399 { 400 String classificationAttributePath = getClassificationAttribute(rootPage); 401 if (StringUtils.isBlank(classificationAttributePath)) 402 { 403 // No classification attribute defined, so no transitional page 404 return new ClassificationType.None(); 405 } 406 String contentTypeId = getContentTypeId(rootPage); 407 ContentType contentType = _cTypeEP.getExtension(contentTypeId); 408 409 if (contentType == null) 410 { 411 getLogger().warn("Can not classify UGC content of type '" + contentTypeId + "' on root page " + rootPage.getId()); 412 } 413 else if (contentType.hasModelItem(classificationAttributePath)) 414 { 415 ModelItem modelItem = contentType.getModelItem(classificationAttributePath); 416 if (modelItem instanceof ContentAttributeDefinition) 417 { 418 String attributeContentType = ((ContentAttributeDefinition) modelItem).getContentTypeId(); 419 return new ClassificationType.TypeContent(this, rootPage, attributeContentType); 420 } 421 else if (modelItem instanceof ElementDefinition<?>) 422 { 423 @SuppressWarnings("unchecked") 424 Enumerator<String> enumerator = ((ElementDefinition<String>) modelItem).getEnumerator(); 425 if (enumerator != null) 426 { 427 return new ClassificationType.TypeEnum(this, rootPage, enumerator); 428 } 429 } 430 } 431 432 return new ClassificationType.None(); 433 } 434 435 /** 436 * Get contents under transitional page 437 * @param rootPage the root page 438 * @param metadataValue the metadata value (linked to the transitional page) 439 * @return list of contents under transitional page 440 */ 441 public AmetysObjectIterable<Content> getContentsForTransitionalPage(Page rootPage, String metadataValue) 442 { 443 String classificationMetadata = getClassificationAttribute(rootPage); 444 445 String lang = rootPage.getSitemapName(); 446 String contentType = getContentTypeId(rootPage); 447 448 ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType); 449 StringExpression metadataExpression = new StringExpression(classificationMetadata, Operator.EQ, metadataValue); 450 451 StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName()); 452 Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE)); 453 Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr); 454 455 Expression finalExpr = new AndExpression(contentTypeExp, metadataExpression, new LanguageExpression(Operator.EQ, lang), fullSiteExpr); 456 457 SortCriteria sort = new SortCriteria(); 458 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 459 460 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 461 462 return _resolver.query(xPathQuery); 463 } 464 465 /** 466 * Determines if given content is part of a transitional page 467 * @param rootPage the root page 468 * @param metadataValue the metadata value (linked to the transitional page). Cannot be null. 469 * @param content the content 470 * @return true if content is part of the transitional page 471 */ 472 public boolean hasContentForTransitionalPage(Page rootPage, String metadataValue, Content content) 473 { 474 String contentType = getContentTypeId(rootPage); 475 String siteName = rootPage.getSiteName(); 476 String classificationMetadata = getClassificationAttribute(rootPage); 477 478 return _cTypeHelper.isInstanceOf(content, contentType) // match content type 479 && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site 480 && StringUtils.isNotEmpty(classificationMetadata) && metadataValue.equals(((ContentValue) content.getValue(classificationMetadata)).getContentId()); // match classification attribute 481 482 } 483 484 /** 485 * Computes a page id 486 * @param path The path 487 * @param root The root page 488 * @param ugcContent The UGC content 489 * @return The id 490 */ 491 public String computePageId(String path, Page root, Content ugcContent) 492 { 493 // E.g: ugccontent://path?rootId=...&contentId=... 494 return "ugccontent://" + path + "?rootId=" + root.getId() + "&contentId=" + ugcContent.getId(); 495 } 496 497 /** 498 * Gets the UGC page related to the given UG Content id 499 * @param contentId the id of UG Content 500 * @param siteName The site name. Can be nul to get site from content or current site. 501 * @return the UGC page or null if not found 502 */ 503 public UGCPage getUgcPage(String contentId, String siteName) 504 { 505 if (contentId == null) 506 { 507 return null; 508 } 509 510 Content content; 511 try 512 { 513 content = _resolver.resolveById(contentId); 514 } 515 catch (UnknownAmetysObjectException e) 516 { 517 return null; 518 } 519 520 Request request = ContextHelper.getRequest(_context); 521 String site = StringUtils.isNotBlank(siteName) ? siteName : WebHelper.getSiteName(request, content); 522 523 String sitemap = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME); 524 525 for (String type : content.getTypes()) 526 { 527 Optional<UGCPage> ugcPage = getUgcPage(content, site, sitemap, type); 528 if (ugcPage.isPresent()) 529 { 530 return ugcPage.get(); 531 } 532 } 533 534 return null; 535 } 536 537 /** 538 * Gets the UGC page related to the given UG Content for given site, sitemap and type 539 * @param ugcContent the UG Content 540 * @param site the site name 541 * @param sitemap the sitemap name 542 * @param contentType the content type id 543 * @return the UGC page 544 */ 545 public Optional<UGCPage> getUgcPage(Content ugcContent, String site, String sitemap, String contentType) 546 { 547 String language = Optional.of(ugcContent) 548 .map(Content::getLanguage) 549 .orElse(sitemap); 550 Page ugcRootPage = getUGCRootPage(site, language, contentType); 551 552 return Optional.ofNullable(ugcRootPage) 553 .flatMap(root -> getUgcPage(root, ugcContent)); 554 } 555 556 /** 557 * Gets the UGC page related to the given UG Content for given UGC root 558 * @param ugcRootPage the UGC root page 559 * @param ugcContent the UG Content 560 * @return the UGC page 561 */ 562 public Optional<UGCPage> getUgcPage(Page ugcRootPage, Content ugcContent) 563 { 564 String path = _getPath(ugcRootPage, ugcContent); 565 return Optional.ofNullable(path) 566 .map(p -> computePageId(p, ugcRootPage, ugcContent)) 567 .map(this::_silentResolve); 568 } 569 570 private String _getPath(Page ugcRootPage, Content ugcContent) 571 { 572 try 573 { 574 ClassificationType transtionalPageType = _getClassificationType(ugcRootPage); 575 if (transtionalPageType instanceof ClassificationType.None) 576 { 577 return "_root"; 578 } 579 else 580 { 581 TransitionalPageInformation transitionalPageInfo = transtionalPageType.getTransitionalPage(ugcContent); 582 return transitionalPageInfo.getKey(); 583 } 584 } 585 catch (Exception e) 586 { 587 getLogger().error("Cannot get path for root {} and content {}", ugcRootPage, ugcContent, e); 588 return null; 589 } 590 } 591 592 private UGCPage _silentResolve(String id) 593 { 594 try 595 { 596 return _resolver.resolveById(id); 597 } 598 catch (UnknownAmetysObjectException e) 599 { 600 return null; 601 } 602 } 603}