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