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        _addChildren(content, treeConfiguration, nodeInformations); // auto expand first level + recursively if necessary
278
279        return nodeInformations;
280    }
281    
282    /**
283     * Get the node informations
284     * @param contentId The content
285     * @return The informations
286     */
287    @Callable
288    public Map<String, Object> getNodeInformations(String contentId)
289    {
290        Content content = _ametysResolver.resolveById(contentId);
291        return content2Json(content);
292    }
293    
294    /**
295     * Get the default JSON representation of a content of the tree
296     * @param content the content
297     * @return the content as JSON
298     */
299    protected Map<String, Object> content2Json(Content content)
300    {
301        Map<String, Object> infos = new HashMap<>();
302        
303        infos.put("id", "random-id-" + org.ametys.core.util.StringUtils.generateKey() + "-" + Math.round(Math.random() * 10_000));
304        
305        infos.put("contentId", content.getId());
306        infos.put("contenttypesIds", content.getTypes());
307        infos.put("name", content.getName());
308        infos.put("title", content.getTitle());
309
310        infos.put("iconGlyph", _contentTypesHelper.getIconGlyph(content));
311        infos.put("iconDecorator", _contentTypesHelper.getIconDecorator(content));
312        infos.put("iconSmall", _contentTypesHelper.getSmallIcon(content));
313        infos.put("iconMedium", _contentTypesHelper.getMediumIcon(content));
314        infos.put("iconLarge", _contentTypesHelper.getLargeIcon(content));
315        
316        return infos;
317    }
318    
319    /**
320     * Get the default JSON representation of a child content
321     * @param content the content
322     * @param attributePath the path of attribute holding this content
323     * @return the content as JSON
324     */
325    public Map<String, Object> childContent2Json(Content content, String attributePath)
326    {
327        Map<String, Object> childInfo = content2Json(content);
328        childInfo.put("metadataPath", attributePath);
329        return childInfo;
330    }
331    
332    private void _merge(Map<String, List<Content>> childrenContent, Map<String, List<Content>> contents)
333    {
334        for (String key : contents.keySet())
335        {
336            if (!childrenContent.containsKey(key))
337            {
338                childrenContent.put(key, new ArrayList<>());
339            }
340            
341            List<Content> contentsList = childrenContent.get(key);
342            contentsList.addAll(contents.get(key));
343        }
344    }
345
346    private Map<String, List<Content>> _handleAttributeTreeConfigurationElementsChild(ContentType contentType, ModelAwareDataHolder dataHolder, AttributeTreeConfigurationElementsChild attributeTreeConfigurationElementsChild, TreeConfiguration treeConfiguration)
347    {
348        Map<String, List<Content>> childrenContent = new HashMap<>();
349        
350        String attributePath = attributeTreeConfigurationElementsChild.getPath();
351
352        try
353        {
354            Map<String, List<Content>> contents = _handleAttribute(contentType, dataHolder, attributePath);
355            _merge(childrenContent, contents);
356        }
357        catch (Exception e)
358        {
359            throw new IllegalArgumentException("An error occured on the tree configuration '" + treeConfiguration.getId() + "' getting for metadata '" + attributePath + "' on content type '" + contentType.getId() + "'", e);
360        }
361
362        return childrenContent;
363    }
364
365    private Map<String, List<Content>> _handleAttribute(ModelItemContainer modelItemContainer, ModelAwareDataHolder dataHolder, String attributePath)
366    {
367        Map<String, List<Content>> childrenContent = new HashMap<>();
368
369        String currentModelItemName = StringUtils.substringBefore(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
370        
371        ModelItem currentModelItem = modelItemContainer.getChild(currentModelItemName);
372        if (currentModelItem == null)
373        {
374            throw new IllegalArgumentException("No attribute definition for " + currentModelItemName);
375        }
376        
377        
378        if (dataHolder.hasValue(currentModelItemName))
379        {
380            if (currentModelItem instanceof RepeaterDefinition)
381            {
382                ModelAwareRepeater repeater = dataHolder.getRepeater(currentModelItemName);
383                for (ModelAwareRepeaterEntry entry : repeater.getEntries())
384                {
385                    String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
386                    Map<String, List<Content>> contents = _handleAttribute((RepeaterDefinition) currentModelItem, entry, subMetadataId);
387
388                    _merge(childrenContent, contents);
389                }
390            }
391            else if (currentModelItem instanceof CompositeDefinition)
392            {
393                ModelAwareComposite metadata = dataHolder.getComposite(currentModelItemName);
394                
395                String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
396                Map<String, List<Content>> contents = _handleAttribute((CompositeDefinition) currentModelItem, metadata, subMetadataId);
397
398                _merge(childrenContent, contents);
399            }
400            else if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(currentModelItem.getType().getId()))
401            {
402                ContentValue[] contentValues = dataHolder.getValue(currentModelItemName);
403                for (ContentValue contentValue : contentValues)
404                {
405                    String key = currentModelItem.getPath();
406                    Optional<ModifiableContent> optContent = contentValue.getContentIfExists();
407                    if (optContent.isPresent())
408                    {
409                        childrenContent.computeIfAbsent(key, k -> new ArrayList<>()).add(optContent.get());
410                    }
411                    else
412                    {
413                        getLogger().warn("On a data holder, the attribute '{}' is referencing a unexisting content: '{}'", key, contentValue.getContentId());
414                    }
415                }
416            }
417            else
418            {
419                throw new IllegalArgumentException("The metadata definition for " + currentModelItem.getPath() + " is not a content");
420            }
421        }
422        
423        return childrenContent;
424    }
425    
426    /**
427     * Get the tree configuration
428     * @param treeId the tree id
429     * @return the tree configuration
430     */
431    protected TreeConfiguration _getTreeConfiguration(String treeId)
432    {
433        if (StringUtils.isBlank(treeId))
434        {
435            throw new IllegalArgumentException("The tree information cannot be obtain, because 'tree' is null");
436        }
437
438        TreeConfiguration treeConfiguration = _treeExtensionPoint.getExtension(treeId);
439        if (treeConfiguration == null)
440        {
441            throw new IllegalArgumentException("There is no tree configuration for '" + treeId + "'");
442        }
443        return treeConfiguration;
444    }
445
446    /**
447     * Get the parent content of a tree
448     * @param parentId the parent id
449     * @return the parent content of a tree
450     * @throws IllegalArgumentException if an exception occurred
451     */
452    protected Content _getParentContent(String parentId) throws IllegalArgumentException
453    {
454        if (StringUtils.isBlank(parentId))
455        {
456            throw new IllegalArgumentException("The tree information cannot be obtain, because 'node' is null");
457        }
458        
459        try
460        {
461            return _ametysResolver.resolveById(parentId);
462        }
463        catch (Exception e)
464        {
465            throw new IllegalArgumentException("The tree configuration cannot be used on an object that is not a content: " + parentId, e);
466        }
467
468    }
469
470    /**
471     * Should auto expand until some kind of node?
472     * @param treeConfiguration The tree configuration
473     * @return true if should auto expand
474     */
475    protected boolean hasAutoExpandTargets(TreeConfiguration treeConfiguration)
476    {
477        return treeConfiguration.getElements()
478            .stream()
479            .map(TreeConfigurationElements::getContentTypesConfiguration)
480            .flatMap(Collection::stream)
481            .anyMatch(TreeConfigurationContentType::autoExpandTarget);
482    }
483    
484    /**
485     * Should auto expand until some kind of node?
486     * @param treeConfiguration The tree configuration
487     * @param content The content involved
488     * @return true if should auto expand to it
489     */
490    protected boolean isAnAutoExpandTarget(TreeConfiguration treeConfiguration, Content content)
491    {
492        List<String> contentTypes = Arrays.asList(content.getTypes());
493        
494        return treeConfiguration.getElements()
495                .stream()
496                .map(TreeConfigurationElements::getContentTypesConfiguration)
497                .flatMap(Collection::stream)
498                .filter(TreeConfigurationContentType::autoExpandTarget)
499                .map(TreeConfigurationContentType::getContentTypesIds)
500                .anyMatch(ct -> { Set<String> hs = new HashSet<>(ct); hs.retainAll(contentTypes); return hs.size() > 0; });
501    }
502}