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}