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