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