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.cms.content; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Locale; 025import java.util.Map; 026import java.util.Optional; 027import java.util.Set; 028import java.util.stream.Collectors; 029import java.util.stream.Stream; 030 031import javax.jcr.Node; 032import javax.jcr.NodeIterator; 033import javax.jcr.RepositoryException; 034 035import org.apache.avalon.framework.component.Component; 036import org.apache.avalon.framework.context.Context; 037import org.apache.avalon.framework.context.ContextException; 038import org.apache.avalon.framework.context.Contextualizable; 039import org.apache.avalon.framework.service.ServiceException; 040import org.apache.avalon.framework.service.ServiceManager; 041import org.apache.avalon.framework.service.Serviceable; 042import org.apache.cocoon.ProcessingException; 043import org.apache.cocoon.components.ContextHelper; 044import org.apache.commons.collections.CollectionUtils; 045import org.apache.commons.lang3.StringUtils; 046import org.apache.commons.lang3.tuple.ImmutablePair; 047import org.apache.commons.lang3.tuple.Pair; 048 049import org.ametys.cms.ObservationConstants; 050import org.ametys.cms.content.references.OutgoingReferencesHelper; 051import org.ametys.cms.contenttype.ContentType; 052import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 053import org.ametys.cms.contenttype.ContentTypesHelper; 054import org.ametys.cms.data.ContentValue; 055import org.ametys.cms.data.type.ModelItemTypeConstants; 056import org.ametys.cms.repository.Content; 057import org.ametys.cms.repository.DefaultContent; 058import org.ametys.cms.repository.ModifiableContent; 059import org.ametys.cms.repository.WorkflowAwareContent; 060import org.ametys.cms.search.model.SystemProperty; 061import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 062import org.ametys.cms.workflow.AbstractContentWorkflowComponent; 063import org.ametys.core.observation.Event; 064import org.ametys.core.observation.ObservationManager; 065import org.ametys.core.ui.Callable; 066import org.ametys.core.user.CurrentUserProvider; 067import org.ametys.core.util.URIUtils; 068import org.ametys.plugins.repository.AmetysObjectResolver; 069import org.ametys.plugins.repository.AmetysRepositoryException; 070import org.ametys.plugins.repository.RepositoryConstants; 071import org.ametys.plugins.repository.jcr.JCRAmetysObject; 072import org.ametys.plugins.repository.metadata.MultilingualString; 073import org.ametys.plugins.workflow.AbstractWorkflowComponent; 074import org.ametys.plugins.workflow.support.WorkflowProvider; 075import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow; 076import org.ametys.runtime.model.DefinitionContext; 077import org.ametys.runtime.model.ModelItem; 078import org.ametys.runtime.model.View; 079import org.ametys.runtime.model.ViewHelper; 080import org.ametys.runtime.plugin.component.AbstractLogEnabled; 081 082import com.opensymphony.workflow.WorkflowException; 083 084/** 085 * Helper for {@link Content} 086 * 087 */ 088public class ContentHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 089{ 090 /** The component role. */ 091 public static final String ROLE = ContentHelper.class.getName(); 092 093 private AmetysObjectResolver _resolver; 094 private ContentTypesHelper _contentTypesHelper; 095 private ContentTypeExtensionPoint _contentTypeEP; 096 097 private ObservationManager _observationManager; 098 private WorkflowProvider _workflowProvider; 099 private CurrentUserProvider _currentUserProvider; 100 private SystemPropertyExtensionPoint _systemPropertyExtensionPoint; 101 102 private Context _context; 103 104 @Override 105 public void service(ServiceManager smanager) throws ServiceException 106 { 107 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 108 _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE); 109 _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE); 110 _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE); 111 _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE); 112 _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE); 113 _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) smanager.lookup(SystemPropertyExtensionPoint.ROLE); 114 } 115 116 @Override 117 public void contextualize(Context context) throws ContextException 118 { 119 _context = context; 120 } 121 122 /** 123 * Add a content type to an existing content 124 * @param contentId The content id 125 * @param contentTypeId The content type to add 126 * @param actionId The workflow action id 127 * @return The result in a Map 128 * @throws WorkflowException if 129 * @throws AmetysRepositoryException if an error occurred 130 */ 131 @Callable 132 public Map<String, Object> addContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException 133 { 134 return _setContentType(contentId, contentTypeId, actionId, false); 135 } 136 137 /** 138 * Remove a content type to an existing content 139 * @param contentId The content id 140 * @param contentTypeId The content type to add 141 * @param actionId The workflow action id 142 * @return The result in a Map 143 * @throws WorkflowException if 144 * @throws AmetysRepositoryException if an error occurred 145 */ 146 @Callable 147 public Map<String, Object> removeContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException 148 { 149 return _setContentType(contentId, contentTypeId, actionId, true); 150 } 151 152 /** 153 * Add a mixin type to an existing content 154 * @param contentId The content id 155 * @param mixinId The mixin type to add 156 * @param actionId The workflow action id 157 * @return The result in a Map 158 * @throws WorkflowException if 159 * @throws AmetysRepositoryException if an error occurred 160 */ 161 @Callable 162 public Map<String, Object> addMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException 163 { 164 return _setMixinType(contentId, mixinId, actionId, false); 165 } 166 167 /** 168 * Remove a mixin type to an existing content 169 * @param contentId The content id 170 * @param mixinId The mixin type to add 171 * @param actionId The workflow action id 172 * @return The result in a Map 173 * @throws WorkflowException if 174 * @throws AmetysRepositoryException if an error occurred 175 */ 176 @Callable 177 public Map<String, Object> removeMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException 178 { 179 return _setMixinType(contentId, mixinId, actionId, true); 180 } 181 182 /** 183 * Get content edition information. 184 * @param contentId the content ID. 185 * @return a Map containing content edition information. 186 */ 187 @Callable 188 public Map<String, Object> getContentEditionInformation(String contentId) 189 { 190 Map<String, Object> info = new HashMap<>(); 191 192 Content content = _resolver.resolveById(contentId); 193 194 info.put("hasIndexingReferences", hasIndexingReferences(content)); 195 196 return info; 197 } 198 199 /** 200 * Test if the given content has indexing references, i.e. if modifying it 201 * potentially implies reindexing other contents. 202 * @param content the content to test. 203 * @return <code>true</code> if one of the content types or mixins has indexing references, <code>false</code> otherwise. 204 */ 205 public boolean hasIndexingReferences(Content content) 206 { 207 for (String cTypeId : content.getTypes()) 208 { 209 if (_contentTypeEP.hasIndexingReferences(cTypeId)) 210 { 211 return true; 212 } 213 } 214 215 for (String mixinId : content.getMixinTypes()) 216 { 217 if (_contentTypeEP.hasIndexingReferences(mixinId)) 218 { 219 return true; 220 } 221 } 222 223 return false; 224 } 225 226 private Map<String, Object> _setContentType (String contentId, String contentTypeId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException 227 { 228 Map<String, Object> result = new HashMap<>(); 229 230 Content content = _resolver.resolveById(contentId); 231 232 if (content instanceof ModifiableContent) 233 { 234 ModifiableContent modifiableContent = (ModifiableContent) content; 235 236 List<String> currentTypes = new ArrayList<>(Arrays.asList(content.getTypes())); 237 238 boolean hasChange = false; 239 if (remove) 240 { 241 if (currentTypes.size() > 1) 242 { 243 hasChange = currentTypes.remove(contentTypeId); 244 } 245 else 246 { 247 result.put("failure", true); 248 result.put("msg", "empty-list"); 249 } 250 } 251 else if (!currentTypes.contains(contentTypeId)) 252 { 253 ContentType cType = _contentTypeEP.getExtension(contentTypeId); 254 if (cType.isMixin()) 255 { 256 result.put("failure", true); 257 result.put("msg", "no-content-type"); 258 getLogger().error("Content type '{}' is a mixin type. It can not be added as content type.", contentTypeId); 259 } 260 else if (!_contentTypesHelper.isCompatibleContentType(content, contentTypeId)) 261 { 262 result.put("failure", true); 263 result.put("msg", "invalid-content-type"); 264 getLogger().error("Content type '{}' is incompatible with content '{}'.", contentTypeId, contentId); 265 } 266 else 267 { 268 currentTypes.add(contentTypeId); 269 hasChange = true; 270 } 271 } 272 273 if (hasChange) 274 { 275 // TODO check if the content type is compatible 276 modifiableContent.setTypes(currentTypes.toArray(new String[currentTypes.size()])); 277 modifiableContent.saveChanges(); 278 279 if (content instanceof WorkflowAwareContent) 280 { 281 282 WorkflowAwareContent waContent = (WorkflowAwareContent) content; 283 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent); 284 285 Map<String, Object> inputs = new HashMap<>(); 286 inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content); 287 inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<String, Object>()); 288 inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>()); 289 290 workflow.doAction(waContent.getWorkflowId(), actionId, inputs); 291 } 292 293 result.put("success", true); 294 295 Map<String, Object> eventParams = new HashMap<>(); 296 eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent); 297 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId); 298 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams)); 299 } 300 } 301 else 302 { 303 result.put("failure", true); 304 result.put("msg", "no-modifiable-content"); 305 getLogger().error("Can not modified content types to a non-modifiable content '{}'.", contentId); 306 } 307 308 return result; 309 } 310 311 private Map<String, Object> _setMixinType (String contentId, String mixinId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException 312 { 313 Map<String, Object> result = new HashMap<>(); 314 315 Content content = _resolver.resolveById(contentId); 316 317 if (content instanceof ModifiableContent) 318 { 319 ModifiableContent modifiableContent = (ModifiableContent) content; 320 321 List<String> currentMixins = new ArrayList<>(Arrays.asList(content.getMixinTypes())); 322 323 boolean hasChange = false; 324 if (remove) 325 { 326 hasChange = currentMixins.remove(mixinId); 327 } 328 else if (!currentMixins.contains(mixinId)) 329 { 330 ContentType cType = _contentTypeEP.getExtension(mixinId); 331 if (!cType.isMixin()) 332 { 333 result.put("failure", true); 334 result.put("msg", "no-mixin"); 335 getLogger().error("The content type '{}' is not a mixin type, it can be not be added as a mixin.", mixinId); 336 } 337 else if (!_contentTypesHelper.isCompatibleContentType(content, mixinId)) 338 { 339 result.put("failure", true); 340 result.put("msg", "invalid-mixin"); 341 getLogger().error("Mixin '{}' is incompatible with content '{}'.", mixinId, contentId); 342 } 343 else 344 { 345 currentMixins.add(mixinId); 346 hasChange = true; 347 } 348 } 349 350 if (hasChange) 351 { 352 // TODO check if the content type is compatible 353 modifiableContent.setMixinTypes(currentMixins.toArray(new String[currentMixins.size()])); 354 modifiableContent.saveChanges(); 355 356 if (content instanceof WorkflowAwareContent) 357 { 358 WorkflowAwareContent waContent = (WorkflowAwareContent) content; 359 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent); 360 361 Map<String, Object> inputs = new HashMap<>(); 362 inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content); 363 inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<String, Object>()); 364 inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>()); 365 366 workflow.doAction(waContent.getWorkflowId(), actionId, inputs); 367 } 368 369 result.put("success", true); 370 371 Map<String, Object> eventParams = new HashMap<>(); 372 eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent); 373 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId); 374 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams)); 375 } 376 } 377 else 378 { 379 result.put("failure", true); 380 result.put("msg", "no-modifiable-content"); 381 getLogger().error("Can not modified mixins to a non-modifiable content '{}'.", contentId); 382 } 383 384 return result; 385 } 386 387 /** 388 * Converts the content attribute definitions in a JSON Map 389 * @param contentId the content identifier 390 * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise 391 * @return the content attribute definitions as a JSON Map 392 * @throws ProcessingException if an error occurs when converting the definitions 393 */ 394 @Callable 395 public Map<String, Object> getContentAttributeDefinitionsAsJSON(String contentId, boolean isEdition) throws ProcessingException 396 { 397 return getContentAttributeDefinitionsAsJSON(contentId, List.of(), isEdition); 398 } 399 400 /** 401 * Converts the given content attribute definitions in a JSON Map 402 * @param contentId the content identifier 403 * @param attibutePaths the paths of the attribute definitions to convert 404 * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise 405 * @return the content attribute definitions as a JSON Map 406 * @throws ProcessingException if an error occurs when converting the definitions 407 */ 408 @Callable 409 public Map<String, Object> getContentAttributeDefinitionsAsJSON(String contentId, List<String> attibutePaths, boolean isEdition) throws ProcessingException 410 { 411 Content content = _resolver.resolveById(contentId); 412 View view = View.of(getContentTypes(content), attibutePaths.toArray(new String[attibutePaths.size()])); 413 DefinitionContext context = DefinitionContext.newInstance().withEdition(isEdition).withObject(content); 414 return Map.of("attributes", view.toJSON(context)); 415 } 416 417 /** 418 * Converts the content view with the given name in a JSON Map 419 * @param contentId the content identifier 420 * @param viewName the name of the view to convert 421 * @param fallbackViewName the name of the view to convert if the initial was not found. Can be null. 422 * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise 423 * @return the view as a JSON Map 424 * @throws ProcessingException if an error occurs when converting the view 425 */ 426 @Callable 427 public Map<String, Object> getContentViewAsJSON(String contentId, String viewName, String fallbackViewName, boolean isEdition) throws ProcessingException 428 { 429 assert StringUtils.isNotEmpty(viewName); 430 assert StringUtils.isNotEmpty(fallbackViewName); 431 432 Content content = _resolver.resolveById(contentId); 433 434 ContextHelper.getRequest(_context).setAttribute(Content.class.getName(), content); 435 436 View view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes()); 437 if (isEdition) 438 { 439 if (ViewHelper.areItemsPresentsOnlyOnce(view)) 440 { 441 view = ViewHelper.getTruncatedView(view); 442 } 443 else 444 { 445 throw new ProcessingException("The view '" + view.getName() + "' cannot be used in edition mode, some items appear more than once."); 446 } 447 } 448 449 DefinitionContext context = DefinitionContext.newInstance().withEdition(isEdition).withObject(content); 450 return Map.of("view", view.toJSON(context)); 451 } 452 453 /** 454 * Retrieves a {@link Collection} containing all content types of the given content 455 * @param content the content 456 * @return all content types of the content 457 */ 458 public Collection<ContentType> getContentTypes(Content content) 459 { 460 return getContentTypes(content, true); 461 } 462 463 /** 464 * Retrieves a {@link Collection} containing content types of the given content 465 * @param content the content 466 * @param includeMixins <code>true</code> to retrieve the mixins the the collection, <code>false</code> otherwise 467 * @return content types of the content 468 */ 469 public Collection<ContentType> getContentTypes(Content content, boolean includeMixins) 470 { 471 Collection<ContentType> contentTypes = _getContentTypesFromIds(content.getTypes()); 472 473 if (includeMixins) 474 { 475 contentTypes.addAll(_getContentTypesFromIds(content.getMixinTypes())); 476 } 477 478 return Collections.unmodifiableCollection(contentTypes); 479 } 480 481 private Collection<ContentType> _getContentTypesFromIds(String[] contentTypeIds) 482 { 483 Collection<ContentType> contentTypes = new ArrayList<>(); 484 485 for (String contentTypeId : contentTypeIds) 486 { 487 ContentType contentType = _contentTypeEP.getExtension(contentTypeId); 488 if (contentType != null) 489 { 490 contentTypes.add(contentType); 491 } 492 else 493 { 494 getLogger().warn("Unknown content type identifier: {}", contentTypeId); 495 } 496 } 497 498 return contentTypes; 499 } 500 501 /** 502 * Determines if the given content has some of its types that are unknown (the extension does not exist) 503 * @param content the content 504 * @return <code>true</code> if at least one of the types of the content is unknown, <code>false</code> otherwise 505 */ 506 public List<String> getUnknownContentTypeIds(Content content) 507 { 508 return getUnknownContentTypeIds(content, true); 509 } 510 511 /** 512 * Determines if the given content has some of its types that are unknown (the extension does not exist) 513 * @param content the content 514 * @param checkMixins <code>true</code> to check unknown content types in mixin types 515 * @return <code>true</code> if at least one of the types of the content is unknown, <code>false</code> otherwise 516 */ 517 public List<String> getUnknownContentTypeIds(Content content, boolean checkMixins) 518 { 519 List<String> unknownContentTypeIds = _getUnknownContentTypeIds(content.getTypes()); 520 521 if (checkMixins) 522 { 523 unknownContentTypeIds.addAll(_getUnknownContentTypeIds(content.getMixinTypes())); 524 } 525 526 return unknownContentTypeIds; 527 } 528 529 private List<String> _getUnknownContentTypeIds(String[] contentTypeIds) 530 { 531 List<String> unknownContentTypeIds = new ArrayList<>(); 532 for (String contentTypeId : contentTypeIds) 533 { 534 if (!_contentTypeEP.hasExtension(contentTypeId)) 535 { 536 unknownContentTypeIds.add(contentTypeId); 537 } 538 } 539 540 return unknownContentTypeIds; 541 } 542 543 /** 544 * Determines if the content is a reference table content type 545 * @param content The content 546 * @return true if content is a reference table 547 */ 548 public boolean isReferenceTable(Content content) 549 { 550 for (String cTypeId : content.getTypes()) 551 { 552 ContentType cType = _contentTypeEP.getExtension(cTypeId); 553 if (cType != null) 554 { 555 if (!cType.isReferenceTable()) 556 { 557 return false; 558 } 559 } 560 else 561 { 562 getLogger().warn("Unable to determine if a content is a reference table, unknown content type : '{}'.", cTypeId); 563 } 564 } 565 return true; 566 } 567 568 /** 569 * Determines if a content is a multilingual content 570 * @param content The content 571 * @return <code>true</code> if the content is an instance of content type 572 */ 573 public boolean isMultilingual(Content content) 574 { 575 for (String cTypeId : content.getTypes()) 576 { 577 ContentType cType = _contentTypeEP.getExtension(cTypeId); 578 if (cType != null && cType.isMultilingual()) 579 { 580 return true; 581 } 582 } 583 return false; 584 } 585 586 /** 587 * Determines if the content is a simple content type 588 * @param content The content 589 * @return true if content is simple 590 */ 591 public boolean isSimple (Content content) 592 { 593 for (String cTypeId : content.getTypes()) 594 { 595 ContentType cType = _contentTypeEP.getExtension(cTypeId); 596 if (cType != null) 597 { 598 if (!cType.isSimple()) 599 { 600 return false; 601 } 602 } 603 else 604 { 605 getLogger().warn("Unable to determine if a content is simple, unknown content type : '{}'.", cTypeId); 606 } 607 } 608 return true; 609 } 610 611 /** 612 * Determines if the content is archived 613 * @param content the content 614 * @return true if the content is archived 615 */ 616 public boolean isArchivedContent(Content content) 617 { 618 boolean canBeArchived = Stream.of(content.getTypes()) 619 .filter(_contentTypesHelper::isArchivedContentType) 620 .findAny() 621 .isPresent(); 622 623 return canBeArchived && content.getValue("archived", false, false); 624 } 625 626 /** 627 * Get the typed value(s) of a content at given path. 628 * The path can represent a system property id or a path of an attribute into the content or an attribute on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/attribute'. 629 * The returned value is typed. 630 * @param content The content 631 * @param fieldPath The field id or the path to the attribute, separated by '/' 632 * @return The typed final value(s). If the final field is multiple, or contained into a repeater or multiple 'CONTENT' attribute, the returned value will be a Collection 633 */ 634 public Object getValue(Content content, String fieldPath) 635 { 636 if (StringUtils.isEmpty(fieldPath)) 637 { 638 return null; 639 } 640 641 // Manage System Properties 642 String[] pathSegments = fieldPath.split(ModelItem.ITEM_PATH_SEPARATOR); 643 String propertyName = pathSegments[pathSegments.length - 1]; 644 645 if (_systemPropertyExtensionPoint.hasExtension(propertyName)) 646 { 647 if (_systemPropertyExtensionPoint.isDisplayable(propertyName)) 648 { 649 SystemProperty systemProperty = _systemPropertyExtensionPoint.getExtension(propertyName); 650 return _getSystemPropertyValue(content, pathSegments, systemProperty); 651 } 652 else 653 { 654 throw new IllegalArgumentException("The system property '" + propertyName + "' is not displayable."); 655 } 656 } 657 else if (content.hasDefinition(fieldPath)) 658 { 659 Object value = content.getValue(fieldPath, true); 660 if (value instanceof Object[]) 661 { 662 return Arrays.asList((Object[]) value); 663 } 664 else 665 { 666 return value; 667 } 668 } 669 else 670 { 671 getLogger().warn("Unknown data at path '{}' for content {}. No corresponding system property nor attribute definition found." , fieldPath, content); 672 return null; 673 } 674 } 675 676 @SuppressWarnings("unchecked") 677 private Object _getSystemPropertyValue(Content content, String[] pathSegments, SystemProperty systemProperty) 678 { 679 String contentFieldPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1); 680 List<Content> contentsContainingSystemProperty = getTargetContents(content, contentFieldPath); 681 if (contentsContainingSystemProperty.size() == 1) 682 { 683 Object value = systemProperty.getValue(contentsContainingSystemProperty.get(0)); 684 685 if (value instanceof Object[]) 686 { 687 return Arrays.asList((Object[]) value); 688 } 689 else 690 { 691 return value; 692 } 693 } 694 else 695 { 696 List<Object> values = new ArrayList<>(); 697 for (Content contentContainingSystemProperty : contentsContainingSystemProperty) 698 { 699 Object value = systemProperty.getValue(contentContainingSystemProperty); 700 701 if (value instanceof Object[]) 702 { 703 values.addAll(Arrays.asList((Object[]) value)); 704 } 705 else 706 { 707 values.add(value); 708 } 709 } 710 return values; 711 } 712 } 713 714 /** 715 * Get the content from which to get the system property. 716 * @param sourceContent The source content. 717 * @param fieldPath The field path 718 * @return The target content. 719 */ 720 public Content getTargetContent(Content sourceContent, String fieldPath) 721 { 722 return getTargetContents(sourceContent, fieldPath) 723 .stream() 724 .findFirst() 725 .orElse(null); 726 } 727 728 /** 729 * Get the contents from which to get the system property. 730 * @param sourceContent The source content. 731 * @param fieldPath The field path 732 * @return The target contents. 733 */ 734 public List<Content> getTargetContents(Content sourceContent, String fieldPath) 735 { 736 if (StringUtils.isBlank(fieldPath)) 737 { 738 return List.of(sourceContent); 739 } 740 else 741 { 742 ModelItem definition = sourceContent.getDefinition(fieldPath); 743 if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId())) 744 { 745 return _transformToStream(sourceContent.getValue(fieldPath, true)) // Get the value and transform it to stream (depending of null, single or multiple value) 746 .map(ContentValue::getContentIfExists) 747 .filter(Optional::isPresent) // Keep only existing contents 748 .map(Optional::get) 749 .collect(Collectors.toList()); 750 } 751 else 752 { 753 // The item at the given path is not of type content 754 return List.of(); 755 } 756 } 757 } 758 759 private Stream<ContentValue> _transformToStream(Object value) 760 { 761 if (value == null) 762 { 763 return Stream.of(); 764 } 765 else if (value.getClass().isArray()) 766 { 767 return Stream.of((ContentValue[]) value); 768 } 769 return Stream.of((ContentValue) value); 770 } 771 772 /** 773 * Get the title of a content.<br> 774 * If the content is a multilingual content, the title will be retrieved for the current locale if exists, or for default locale 'en' if exists, or for the first found locale. 775 * @param content The content 776 * @return The title of the content 777 */ 778 public String getTitle(Content content) 779 { 780 Locale defaultLocale = null; 781 782 try 783 { 784 Map objectModel = (Map) _context.get(ContextHelper.CONTEXT_OBJECT_MODEL); 785 if (objectModel != null) 786 { 787 // The object model can be null if #getTitle(content) is called outside a request 788 defaultLocale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true); 789 } 790 } 791 catch (ContextException e) 792 { 793 // There is no context 794 } 795 796 // TODO Use user preference language ? 797 return content.getTitle(defaultLocale); 798 } 799 800 /** 801 * Get the title variants of a multilingual content 802 * @param content The multilingual content 803 * @return the content's title for each locale 804 * @throws IllegalArgumentException if the content is not a multilingual content 805 */ 806 public Map<String, String> getTitleVariants(Content content) 807 { 808 if (!isMultilingual(content)) 809 { 810 throw new IllegalArgumentException("Can not get title variants for a non-multilingual content " + content.getId()); 811 } 812 813 Map<String, String> variants = new HashMap<>(); 814 815 MultilingualString value = content.getValue(Content.ATTRIBUTE_TITLE); 816 for (Locale locale : value.getLocales()) 817 { 818 variants.put(locale.getLanguage(), value.getValue(locale)); 819 } 820 821 return variants; 822 } 823 824 /** 825 * Determines if the content has referencing contents other than whose type is in content types to ignore. 826 * @param content The content to check 827 * @param ignoreContentTypes The content types to ignore for referencing contents 828 * @param includeSubTypes True if sub content types are take into account in ignore content types 829 * @return <code>true</code> if there is at least one Content referencing the content 830 */ 831 public boolean hasReferencingContents(Content content, List<String> ignoreContentTypes, boolean includeSubTypes) 832 { 833 List<String> newIgnoreContentTypes = new ArrayList<>(); 834 newIgnoreContentTypes.addAll(ignoreContentTypes); 835 if (includeSubTypes) 836 { 837 for (String contentType : ignoreContentTypes) 838 { 839 newIgnoreContentTypes.addAll(_contentTypeEP.getSubTypes(contentType)); 840 } 841 } 842 843 for (Content refContent : content.getReferencingContents()) 844 { 845 List<String> contentTypes = Arrays.asList(refContent.getTypes()); 846 if (!CollectionUtils.containsAny(contentTypes, newIgnoreContentTypes)) 847 { 848 return true; 849 } 850 } 851 852 return false; 853 } 854 855 /** 856 * Returns all Contents referencing the given content with their value path 857 * @param content The content to get references 858 * @return the list of pair path / contents 859 */ 860 public List<Pair<String, Content>> getReferencingContents(Content content) 861 { 862 List<Pair<String, Content>> incomingReferences = new ArrayList<>(); 863 try 864 { 865 NodeIterator results = OutgoingReferencesHelper.getContentOutgoingReferences((JCRAmetysObject) content); 866 while (results.hasNext()) 867 { 868 Node node = results.nextNode(); 869 870 Node outgoingRefsNode = node.getParent(); // go up towards node 'ametys-internal:outgoing-references; 871 String path = outgoingRefsNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES_PATH_PROPERTY).getString(); 872 873 Node contentNode = outgoingRefsNode.getParent() // go up towards node 'ametys-internal:root-outgoing-references 874 .getParent(); // go up towards node of the content 875 Content refContent = _resolver.resolve(contentNode, false); 876 877 incomingReferences.add(new ImmutablePair<>(path, refContent)); 878 } 879 } 880 catch (RepositoryException e) 881 { 882 throw new AmetysRepositoryException("Unable to resolve references for content " + content.getId(), e); 883 } 884 885 return incomingReferences; 886 } 887 888 /** 889 * Get the default workflow name for the content. If several workflows are possible, an empty {@link Optional} is returned. 890 * @param content The content 891 * @return The default workflow name or {@link Optional#empty()} if it cannot be determine 892 */ 893 public Optional<String> getDefaultWorkflowName(Content content) 894 { 895 Set<String> defaultWorkflowNames = Stream.of(content.getTypes()) 896 .map(_contentTypeEP::getExtension) 897 // Don't use ContentType.getDefaultWorkflowName because it returns an empty Optional if there are several available workflow names 898 .map(ContentType::getConfiguredDefaultWorkflowNames) 899 .flatMap(Set::stream) 900 .collect(Collectors.toSet()); 901 902 if (defaultWorkflowNames.size() > 1) 903 { 904 getLogger().warn("Several default workflows are defined for content {} : {}.", content.toString(), StringUtils.join(defaultWorkflowNames)); 905 return Optional.empty(); 906 } 907 908 return defaultWorkflowNames 909 .stream() 910 .findFirst() 911 .or(() -> Optional.of(isReferenceTable(content) ? "reference-table" : "content")); 912 913 } 914 915 /** 916 * Get the URL for the HTML view of a content with the needed parameters 917 * @param content the content 918 * @param viewName the view name 919 * @return the content uri 920 */ 921 public String getContentHtmlViewUrl(Content content, String viewName) 922 { 923 return getContentHtmlViewUrl(content, viewName, Map.of()); 924 } 925 926 /** 927 * Get the URL for the HTML view of a content with the needed parameters 928 * @param content the content 929 * @param viewName the view name 930 * @param additionalParams the additional parameters. Can be empty 931 * @return the content uri 932 */ 933 public String getContentHtmlViewUrl(Content content, String viewName, Map<String, String> additionalParams) 934 { 935 return getContentViewUrl(content, viewName, "html", additionalParams); 936 } 937 938 /** 939 * Get the URL for the view of a content with the needed parameters 940 * @param content the content 941 * @param viewName the view name 942 * @param format the output format (html, xml, doc, pdf, ...) 943 * @return the content uri 944 */ 945 public String getContentViewUrl(Content content, String viewName, String format) 946 { 947 return getContentViewUrl(content, viewName, format, Map.of()); 948 } 949 950 /** 951 * Get the URL for the view of a content with the needed parameters 952 * @param content the content 953 * @param viewName the view name 954 * @param format the output format (html, xml, doc, pdf, ...) 955 * @param additionalParams the additional parameters. Can be empty 956 * @return the content uri 957 */ 958 public String getContentViewUrl(Content content, String viewName, String format, Map<String, String> additionalParams) 959 { 960 String uri = "cocoon://_content." + format; 961 Map<String, String> uriParams = getContentViewUrlParameters(content, viewName, format, additionalParams); 962 return URIUtils.buildURI(uri, uriParams); 963 } 964 965 966 /** 967 * Get the needed url parameters for a content view 968 * @param content the content 969 * @param viewName the view name 970 * @param format the output format (html, xml, doc, pdf, ...) 971 * @return the uri parameters 972 */ 973 public Map<String, String> getContentViewUrlParameters(Content content, String viewName, String format) 974 { 975 return getContentViewUrlParameters(content, viewName, format, Map.of()); 976 } 977 978 /** 979 * Get the needed url parameters for a content view 980 * @param content the content 981 * @param viewName the view name 982 * @param format the output format (html, xml, doc, pdf, ...) 983 * @param additionalParams the additional parameters. Can be empty 984 * @return the uri parameters 985 */ 986 public Map<String, String> getContentViewUrlParameters(Content content, String viewName, String format, Map<String, String> additionalParams) 987 { 988 Map<String, String> params = new HashMap<>(); 989 params.put("contentId", content.getId()); 990 params.put("viewName", viewName); 991 params.put("output-format", format); 992 params.putAll(additionalParams); 993 994 return params; 995 } 996}