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