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