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