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.ElementDefinition; 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 private Object _getSystemPropertyValue(Content content, String[] pathSegments, SystemProperty systemProperty) 677 { 678 String contentFieldPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1); 679 List<Content> contentsContainingSystemProperty = getTargetContents(content, contentFieldPath); 680 if (contentsContainingSystemProperty.size() == 1) 681 { 682 Object value = systemProperty.getValue(contentsContainingSystemProperty.get(0)); 683 684 if (value instanceof Object[]) 685 { 686 return Arrays.asList((Object[]) value); 687 } 688 else 689 { 690 return value; 691 } 692 } 693 else 694 { 695 List<Object> values = new ArrayList<>(); 696 for (Content contentContainingSystemProperty : contentsContainingSystemProperty) 697 { 698 Object value = systemProperty.getValue(contentContainingSystemProperty); 699 700 if (value instanceof Object[]) 701 { 702 values.addAll(Arrays.asList((Object[]) value)); 703 } 704 else 705 { 706 values.add(value); 707 } 708 } 709 return values; 710 } 711 } 712 713 /** 714 * Get the content from which to get the system property. 715 * @param sourceContent The source content. 716 * @param fieldPath The field path 717 * @return The target content. 718 */ 719 public Content getTargetContent(Content sourceContent, String fieldPath) 720 { 721 return getTargetContents(sourceContent, fieldPath) 722 .stream() 723 .findFirst() 724 .orElse(null); 725 } 726 727 /** 728 * Get the contents from which to get the system property. 729 * @param sourceContent The source content. 730 * @param fieldPath The field path 731 * @return The target contents. 732 */ 733 public List<Content> getTargetContents(Content sourceContent, String fieldPath) 734 { 735 if (StringUtils.isBlank(fieldPath)) 736 { 737 return List.of(sourceContent); 738 } 739 else 740 { 741 ModelItem definition = sourceContent.getDefinition(fieldPath); 742 if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId())) 743 { 744 List<ContentValue> values; 745 if (((ElementDefinition) definition).isMultiple()) 746 { 747 values = Arrays.stream(sourceContent.getValue(fieldPath, false, new ContentValue[0])) 748 .collect(Collectors.toList()); 749 } 750 else 751 { 752 values = new ArrayList<>(); 753 Optional.ofNullable(sourceContent.getValue(fieldPath)) 754 .map(ContentValue.class::cast) 755 .ifPresent(c -> values.add(c)); 756 } 757 758 return values.stream() 759 .map(ContentValue::getContentIfExists) 760 .filter(Optional::isPresent) // Keep only existing contents 761 .map(Optional::get) 762 .collect(Collectors.toList()); 763 764 } 765 else 766 { 767 // The item at the given path is not of type content 768 return List.of(); 769 } 770 } 771 } 772 773 /** 774 * Get the title of a content.<br> 775 * 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. 776 * @param content The content 777 * @return The title of the content 778 */ 779 public String getTitle(Content content) 780 { 781 Locale defaultLocale = null; 782 783 try 784 { 785 Map objectModel = (Map) _context.get(ContextHelper.CONTEXT_OBJECT_MODEL); 786 if (objectModel != null) 787 { 788 // The object model can be null if #getTitle(content) is called outside a request 789 defaultLocale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true); 790 } 791 } 792 catch (ContextException e) 793 { 794 // There is no context 795 } 796 797 // TODO Use user preference language ? 798 return content.getTitle(defaultLocale); 799 } 800 801 /** 802 * Get the title variants of a multilingual content 803 * @param content The multilingual content 804 * @return the content's title for each locale 805 * @throws IllegalArgumentException if the content is not a multilingual content 806 */ 807 public Map<String, String> getTitleVariants(Content content) 808 { 809 if (!isMultilingual(content)) 810 { 811 throw new IllegalArgumentException("Can not get title variants for a non-multilingual content " + content.getId()); 812 } 813 814 Map<String, String> variants = new HashMap<>(); 815 816 MultilingualString value = content.getValue(Content.ATTRIBUTE_TITLE); 817 for (Locale locale : value.getLocales()) 818 { 819 variants.put(locale.getLanguage(), value.getValue(locale)); 820 } 821 822 return variants; 823 } 824 825 /** 826 * Determines if the content has referencing contents other than whose type is in content types to ignore. 827 * @param content The content to check 828 * @param ignoreContentTypes The content types to ignore for referencing contents 829 * @param includeSubTypes True if sub content types are take into account in ignore content types 830 * @return <code>true</code> if there is at least one Content referencing the content 831 */ 832 public boolean hasReferencingContents(Content content, List<String> ignoreContentTypes, boolean includeSubTypes) 833 { 834 List<String> newIgnoreContentTypes = new ArrayList<>(); 835 newIgnoreContentTypes.addAll(ignoreContentTypes); 836 if (includeSubTypes) 837 { 838 for (String contentType : ignoreContentTypes) 839 { 840 newIgnoreContentTypes.addAll(_contentTypeEP.getSubTypes(contentType)); 841 } 842 } 843 844 for (Content refContent : content.getReferencingContents()) 845 { 846 List<String> contentTypes = Arrays.asList(refContent.getTypes()); 847 if (!CollectionUtils.containsAny(contentTypes, newIgnoreContentTypes)) 848 { 849 return true; 850 } 851 } 852 853 return false; 854 } 855 856 /** 857 * Returns all Contents referencing the given content with their value path 858 * @param content The content to get references 859 * @return the list of pair path / contents 860 */ 861 public List<Pair<String, Content>> getReferencingContents(Content content) 862 { 863 List<Pair<String, Content>> incomingReferences = new ArrayList<>(); 864 try 865 { 866 NodeIterator results = OutgoingReferencesHelper.getContentOutgoingReferences((JCRAmetysObject) content); 867 while (results.hasNext()) 868 { 869 Node node = results.nextNode(); 870 871 Node outgoingRefsNode = node.getParent(); // go up towards node 'ametys-internal:outgoing-references; 872 String path = outgoingRefsNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES_PATH_PROPERTY).getString(); 873 874 Node contentNode = outgoingRefsNode.getParent() // go up towards node 'ametys-internal:root-outgoing-references 875 .getParent(); // go up towards node of the content 876 Content refContent = _resolver.resolve(contentNode, false); 877 878 incomingReferences.add(new ImmutablePair<>(path, refContent)); 879 } 880 } 881 catch (RepositoryException e) 882 { 883 throw new AmetysRepositoryException("Unable to resolve references for content " + content.getId(), e); 884 } 885 886 return incomingReferences; 887 } 888 889 /** 890 * Get the default workflow name for the content. If several workflows are possible, an empty {@link Optional} is returned. 891 * @param content The content 892 * @return The default workflow name or {@link Optional#empty()} if it cannot be determine 893 */ 894 public Optional<String> getDefaultWorkflowName(Content content) 895 { 896 Set<String> defaultWorkflowNames = Stream.of(content.getTypes()) 897 .map(_contentTypeEP::getExtension) 898 // Don't use ContentType.getDefaultWorkflowName because it returns an empty Optional if there are several available workflow names 899 .map(ContentType::getConfiguredDefaultWorkflowNames) 900 .flatMap(Set::stream) 901 .collect(Collectors.toSet()); 902 903 if (defaultWorkflowNames.size() > 1) 904 { 905 getLogger().warn("Several default workflows are defined for content {} : {}.", content.toString(), StringUtils.join(defaultWorkflowNames)); 906 return Optional.empty(); 907 } 908 909 return defaultWorkflowNames 910 .stream() 911 .findFirst() 912 .or(() -> Optional.of(isReferenceTable(content) ? "reference-table" : "content")); 913 914 } 915}