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