001/* 002 * Copyright 2019 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.plugins.contentstree; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.HashSet; 024import java.util.List; 025import java.util.Map; 026import java.util.Optional; 027import java.util.Set; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.service.ServiceException; 031import org.apache.avalon.framework.service.ServiceManager; 032import org.apache.avalon.framework.service.Serviceable; 033import org.apache.commons.lang3.StringUtils; 034 035import org.ametys.cms.contenttype.ContentType; 036import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 037import org.ametys.cms.contenttype.ContentTypesHelper; 038import org.ametys.cms.data.ContentValue; 039import org.ametys.cms.data.type.ModelItemTypeConstants; 040import org.ametys.cms.repository.Content; 041import org.ametys.cms.repository.ModifiableContent; 042import org.ametys.cms.rights.ContentRightAssignmentContext; 043import org.ametys.core.ui.Callable; 044import org.ametys.plugins.repository.AmetysObjectResolver; 045import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 046import org.ametys.plugins.repository.data.holder.group.ModelAwareComposite; 047import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater; 048import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 049import org.ametys.plugins.repository.model.CompositeDefinition; 050import org.ametys.plugins.repository.model.RepeaterDefinition; 051import org.ametys.runtime.model.ModelItem; 052import org.ametys.runtime.model.ModelItemContainer; 053import org.ametys.runtime.plugin.component.AbstractLogEnabled; 054 055/** 056 * Helper for contents tree 057 * 058 */ 059public class ContentsTreeHelper extends AbstractLogEnabled implements Component, Serviceable 060{ 061 /** The Avalon role */ 062 public static final String ROLE = ContentsTreeHelper.class.getName(); 063 064 /** The ametys object resolver instance */ 065 protected AmetysObjectResolver _ametysResolver; 066 /** The tree configuration EP instance */ 067 protected TreeExtensionPoint _treeExtensionPoint; 068 /** The content type EP instance */ 069 protected ContentTypeExtensionPoint _contentTypesEP; 070 /** The content types helper instance */ 071 protected ContentTypesHelper _contentTypesHelper; 072 073 @Override 074 public void service(ServiceManager smanager) throws ServiceException 075 { 076 _ametysResolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 077 _treeExtensionPoint = (TreeExtensionPoint) smanager.lookup(TreeExtensionPoint.ROLE); 078 _contentTypesEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE); 079 _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE); 080 } 081 082 /** 083 * Determines if the content has children contents according the tree configuration 084 * @param content the root content 085 * @param treeConfiguration the tree configuration 086 * @return true if the content has children contents 087 */ 088 public boolean hasChildrenContent(Content content, TreeConfiguration treeConfiguration) 089 { 090 return !getChildrenContent(content, treeConfiguration).isEmpty(); 091 } 092 093 /** 094 * Get the children contents according the tree configuration 095 * @param parentContent the root content 096 * @param treeConfiguration the tree configuration 097 * @return the children content for each child attributes 098 */ 099 public Map<String, List<Content>> getChildrenContent(Content parentContent, TreeConfiguration treeConfiguration) 100 { 101 Map<String, List<Content>> childrenContent = new HashMap<>(); 102 103 // For each content type of the content 104 for (String contentTypeId : parentContent.getTypes()) 105 { 106 // Loop over all possible elements for the tree 107 for (TreeConfigurationElements treeConfigurationElements : treeConfiguration.getElements()) 108 { 109 // Check for a match between the element and the content type of the content 110 for (TreeConfigurationContentType treeConfigurationContentType : treeConfigurationElements.getContentTypesConfiguration()) 111 { 112 if (treeConfigurationContentType.getContentTypesIds().contains(contentTypeId)) 113 { 114 ContentType contentType = _contentTypesEP.getExtension(contentTypeId); 115 116 // Add all required children for this element 117 for (TreeConfigurationElementsChild treeConfigurationElementsChild : treeConfigurationElements.getChildren()) 118 { 119 if (treeConfigurationElementsChild instanceof AttributeTreeConfigurationElementsChild) 120 { 121 // Get the attribute 122 Map<String, List<Content>> contents = _handleAttributeTreeConfigurationElementsChild(contentType, parentContent, (AttributeTreeConfigurationElementsChild) treeConfigurationElementsChild, treeConfiguration); 123 _merge(childrenContent, contents); 124 } 125 else 126 { 127 throw new IllegalArgumentException("The child configuration element class <" + treeConfigurationElementsChild + "> is not supported in tree '" + treeConfiguration.getId() + "'"); 128 } 129 } 130 } 131 } 132 133 } 134 } 135 136 return childrenContent; 137 } 138 139 /** 140 * Get the children contents according the tree configuration 141 * @param contentId the parent content 142 * @param path the path of the content in the current tree 143 * @param treeId the tree configuration 144 * @return the children content 145 */ 146 @Callable(rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0) 147 public Map<String, Object> getChildrenContent(String contentId, List<String> path, String treeId) 148 { 149 TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId); 150 Content parentContent = _getParentContent(contentId); 151 152 Map<String, Object> infos = new HashMap<>(); 153 154 _addChildren(parentContent, path, treeConfiguration, infos); 155 156 infos.putAll(getNodeInformations(contentId, path)); 157 158 return infos; 159 } 160 161 /** 162 * Add the json info to list the children of a content 163 * @param content The content 164 * @param path the content path in the current tree 165 * @param treeConfiguration The current tree configuration 166 * @param infos The infos where to add the children key 167 */ 168 protected void _addChildren(Content content, List<String> path, TreeConfiguration treeConfiguration, Map<String, Object> infos) 169 { 170 Map<String, List<Content>> children = getChildrenContent(content, treeConfiguration); 171 172 boolean hasAtLeastOneAutoExpand = hasAutoExpandTargets(treeConfiguration); 173 174 List<Map<String, Object>> childrenInfos = new ArrayList<>(); 175 infos.put("children", childrenInfos); 176 177 for (String attributePath : children.keySet()) 178 { 179 for (Content childContent : children.get(attributePath)) 180 { 181 boolean expand = hasAtLeastOneAutoExpand && !isAnAutoExpandTarget(treeConfiguration, childContent); 182 183 List<String> contentPath = new ArrayList<>(path); 184 contentPath.add(childContent.getId()); 185 186 Map<String, Object> childInfo = content2Json(childContent, contentPath); 187 childInfo.put("metadataPath", attributePath); 188 childInfo.put("expanded", expand); 189 190 if (expand) 191 { 192 _addChildren(childContent, contentPath, treeConfiguration, childInfo); 193 } 194 195 if (!hasChildrenContent(childContent, treeConfiguration)) 196 { 197 // childInfo.put("leaf", true); 198 childInfo.put("children", Collections.EMPTY_LIST); 199 } 200 else 201 { 202 childInfo.put("leaf", false); 203 childInfo.put("isExpanded", false); 204 } 205 206 childrenInfos.add(childInfo); 207 } 208 } 209 210 } 211 212 /** 213 * Get the path of children content which match filter regexp 214 * @param parentContentId The id of content to start search 215 * @param treeId The id of tree configuration 216 * @param value the value to match 217 * @return the matching paths composed by contents id separated by ';' 218 */ 219 @Callable(rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0) 220 public List<String> filterChildrenContentByRegExp(String parentContentId, String treeId, String value) 221 { 222 List<String> matchingPaths = new ArrayList<>(); 223 224 Content parentContent = _ametysResolver.resolveById(parentContentId); 225 TreeConfiguration treeConfiguration = _treeExtensionPoint.getExtension(treeId); 226 227 String toMatch = StringUtils.stripAccents(value.toLowerCase()).trim(); 228 229 Map<String, List<Content>> childrenContentByAttributes = getChildrenContent(parentContent, treeConfiguration); 230 for (List<Content> childrenContent : childrenContentByAttributes.values()) 231 { 232 for (Content childContent : childrenContent) 233 { 234 _getMatchingContents(childContent, toMatch, treeConfiguration, matchingPaths, parentContentId); 235 } 236 } 237 238 return matchingPaths; 239 } 240 241 private void _getMatchingContents(Content content, String value, TreeConfiguration treeConfiguration, List<String> matchingPaths, String parentPath) 242 { 243 if (isContentMatching(content, value)) 244 { 245 matchingPaths.add(parentPath + ";" + content.getId()); 246 } 247 248 Map<String, List<Content>> childrenContentByAttributes = getChildrenContent(content, treeConfiguration); 249 for (List<Content> childrenContent : childrenContentByAttributes.values()) 250 { 251 for (Content childContent : childrenContent) 252 { 253 _getMatchingContents(childContent, value, treeConfiguration, matchingPaths, parentPath + ";" + content.getId()); 254 } 255 } 256 } 257 258 /** 259 * Determines if content matches the filter regexp 260 * @param content the content 261 * @param value the value to match 262 * @return true if the content match 263 */ 264 protected boolean isContentMatching(Content content, String value) 265 { 266 String title = StringUtils.stripAccents(content.getTitle().toLowerCase()); 267 return title.contains(value); 268 } 269 270 /** 271 * Get the root node informations 272 * @param contentId The content 273 * @param treeId The contents tree id 274 * @return The informations 275 */ 276 @Callable(rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0) 277 public Map<String, Object> getRootNodeInformations(String contentId, String treeId) 278 { 279 TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId); 280 Content content = _ametysResolver.resolveById(contentId); 281 282 Map<String, Object> nodeInformations = content2Json(content, List.of(contentId)); 283 nodeInformations.put("id", "root"); // Root id should not be random, for delete op 284 _addChildren(content, List.of(contentId), treeConfiguration, nodeInformations); // auto expand first level + recursively if necessary 285 286 return nodeInformations; 287 } 288 289 /** 290 * Get the node informations 291 * @param contentId The content 292 * @param path The path of the content in the tree 293 * @return The informations 294 */ 295 @Callable(rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0) 296 public Map<String, Object> getNodeInformations(String contentId, List<String> path) 297 { 298 Content content = _ametysResolver.resolveById(contentId); 299 return content2Json(content, path); 300 } 301 302 /** 303 * Get the default JSON representation of a content of the tree 304 * @param content the content 305 * @param path The path of the content in the tree 306 * @return the content as JSON 307 */ 308 protected Map<String, Object> content2Json(Content content, List<String> path) 309 { 310 Map<String, Object> infos = new HashMap<>(); 311 312 infos.put("id", "random-id-" + org.ametys.core.util.StringUtils.generateKey() + "-" + Math.round(Math.random() * 10_000)); 313 314 infos.put("contentId", content.getId()); 315 infos.put("contenttypesIds", content.getTypes()); 316 infos.put("name", content.getName()); 317 infos.put("title", content.getTitle()); 318 319 infos.put("iconGlyph", _contentTypesHelper.getIconGlyph(content)); 320 infos.put("iconDecorator", _contentTypesHelper.getIconDecorator(content)); 321 infos.put("iconSmall", _contentTypesHelper.getSmallIcon(content)); 322 infos.put("iconMedium", _contentTypesHelper.getMediumIcon(content)); 323 infos.put("iconLarge", _contentTypesHelper.getLargeIcon(content)); 324 325 return infos; 326 } 327 328 /** 329 * Get the default JSON representation of a child content 330 * @param content the content 331 * @param path The path of the content in the tree 332 * @param attributePath the path of attribute holding this content 333 * @return the content as JSON 334 */ 335 public Map<String, Object> childContent2Json(Content content, List<String> path, String attributePath) 336 { 337 Map<String, Object> childInfo = content2Json(content, path); 338 childInfo.put("metadataPath", attributePath); 339 return childInfo; 340 } 341 342 private void _merge(Map<String, List<Content>> childrenContent, Map<String, List<Content>> contents) 343 { 344 for (String key : contents.keySet()) 345 { 346 if (!childrenContent.containsKey(key)) 347 { 348 childrenContent.put(key, new ArrayList<>()); 349 } 350 351 List<Content> contentsList = childrenContent.get(key); 352 contentsList.addAll(contents.get(key)); 353 } 354 } 355 356 private Map<String, List<Content>> _handleAttributeTreeConfigurationElementsChild(ContentType contentType, ModelAwareDataHolder dataHolder, AttributeTreeConfigurationElementsChild attributeTreeConfigurationElementsChild, TreeConfiguration treeConfiguration) 357 { 358 Map<String, List<Content>> childrenContent = new HashMap<>(); 359 360 String attributePath = attributeTreeConfigurationElementsChild.getPath(); 361 362 try 363 { 364 Map<String, List<Content>> contents = _handleAttribute(contentType, dataHolder, attributePath); 365 _merge(childrenContent, contents); 366 } 367 catch (Exception e) 368 { 369 throw new IllegalArgumentException("An error occured on the tree configuration '" + treeConfiguration.getId() + "' getting for metadata '" + attributePath + "' on content type '" + contentType.getId() + "'", e); 370 } 371 372 return childrenContent; 373 } 374 375 private Map<String, List<Content>> _handleAttribute(ModelItemContainer modelItemContainer, ModelAwareDataHolder dataHolder, String attributePath) 376 { 377 Map<String, List<Content>> childrenContent = new HashMap<>(); 378 379 String currentModelItemName = StringUtils.substringBefore(attributePath, ModelItem.ITEM_PATH_SEPARATOR); 380 381 ModelItem currentModelItem = modelItemContainer.getChild(currentModelItemName); 382 if (currentModelItem == null) 383 { 384 throw new IllegalArgumentException("No attribute definition for " + currentModelItemName); 385 } 386 387 388 if (dataHolder.hasValue(currentModelItemName)) 389 { 390 if (currentModelItem instanceof RepeaterDefinition) 391 { 392 ModelAwareRepeater repeater = dataHolder.getRepeater(currentModelItemName); 393 for (ModelAwareRepeaterEntry entry : repeater.getEntries()) 394 { 395 String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR); 396 Map<String, List<Content>> contents = _handleAttribute((RepeaterDefinition) currentModelItem, entry, subMetadataId); 397 398 _merge(childrenContent, contents); 399 } 400 } 401 else if (currentModelItem instanceof CompositeDefinition) 402 { 403 ModelAwareComposite metadata = dataHolder.getComposite(currentModelItemName); 404 405 String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR); 406 Map<String, List<Content>> contents = _handleAttribute((CompositeDefinition) currentModelItem, metadata, subMetadataId); 407 408 _merge(childrenContent, contents); 409 } 410 else if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(currentModelItem.getType().getId())) 411 { 412 ContentValue[] contentValues = dataHolder.getValue(currentModelItemName); 413 for (ContentValue contentValue : contentValues) 414 { 415 String key = currentModelItem.getPath(); 416 Optional<ModifiableContent> optContent = contentValue.getContentIfExists(); 417 if (optContent.isPresent()) 418 { 419 childrenContent.computeIfAbsent(key, k -> new ArrayList<>()).add(optContent.get()); 420 } 421 else 422 { 423 getLogger().warn("On a data holder, the attribute '{}' is referencing a unexisting content: '{}'", key, contentValue.getContentId()); 424 } 425 } 426 } 427 else 428 { 429 throw new IllegalArgumentException("The metadata definition for " + currentModelItem.getPath() + " is not a content"); 430 } 431 } 432 433 return childrenContent; 434 } 435 436 /** 437 * Get the tree configuration 438 * @param treeId the tree id 439 * @return the tree configuration 440 */ 441 protected TreeConfiguration _getTreeConfiguration(String treeId) 442 { 443 if (StringUtils.isBlank(treeId)) 444 { 445 throw new IllegalArgumentException("The tree information cannot be obtain, because 'tree' is null"); 446 } 447 448 TreeConfiguration treeConfiguration = _treeExtensionPoint.getExtension(treeId); 449 if (treeConfiguration == null) 450 { 451 throw new IllegalArgumentException("There is no tree configuration for '" + treeId + "'"); 452 } 453 return treeConfiguration; 454 } 455 456 /** 457 * Get the parent content of a tree 458 * @param parentId the parent id 459 * @return the parent content of a tree 460 * @throws IllegalArgumentException if an exception occurred 461 */ 462 protected Content _getParentContent(String parentId) throws IllegalArgumentException 463 { 464 if (StringUtils.isBlank(parentId)) 465 { 466 throw new IllegalArgumentException("The tree information cannot be obtain, because 'node' is null"); 467 } 468 469 try 470 { 471 return _ametysResolver.resolveById(parentId); 472 } 473 catch (Exception e) 474 { 475 throw new IllegalArgumentException("The tree configuration cannot be used on an object that is not a content: " + parentId, e); 476 } 477 478 } 479 480 /** 481 * Should auto expand until some kind of node? 482 * @param treeConfiguration The tree configuration 483 * @return true if should auto expand 484 */ 485 protected boolean hasAutoExpandTargets(TreeConfiguration treeConfiguration) 486 { 487 return treeConfiguration.getElements() 488 .stream() 489 .map(TreeConfigurationElements::getContentTypesConfiguration) 490 .flatMap(Collection::stream) 491 .anyMatch(TreeConfigurationContentType::autoExpandTarget); 492 } 493 494 /** 495 * Should auto expand until some kind of node? 496 * @param treeConfiguration The tree configuration 497 * @param content The content involved 498 * @return true if should auto expand to it 499 */ 500 protected boolean isAnAutoExpandTarget(TreeConfiguration treeConfiguration, Content content) 501 { 502 List<String> contentTypes = Arrays.asList(content.getTypes()); 503 504 return treeConfiguration.getElements() 505 .stream() 506 .map(TreeConfigurationElements::getContentTypesConfiguration) 507 .flatMap(Collection::stream) 508 .filter(TreeConfigurationContentType::autoExpandTarget) 509 .map(TreeConfigurationContentType::getContentTypesIds) 510 .anyMatch(ct -> { Set<String> hs = new HashSet<>(ct); hs.retainAll(contentTypes); return hs.size() > 0; }); 511 } 512}