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 path the path of the content in the current tree
142     * @param treeId the tree configuration
143     * @return the children content
144     */
145    @Callable
146    public Map<String, Object> getChildrenContent(String contentId, List<String> path, String treeId)
147    {
148        TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId);
149        Content parentContent = _getParentContent(contentId);
150        
151        Map<String, Object> infos = new HashMap<>();
152
153        _addChildren(parentContent, path, treeConfiguration, infos);
154        
155        infos.putAll(getNodeInformations(contentId, path));
156
157        return infos;
158    }
159    
160    /**
161     * Add the json info to list the children of a content
162     * @param content The content
163     * @param path the content path in the current tree
164     * @param treeConfiguration The current tree configuration
165     * @param infos The infos where to add the children key
166     */
167    protected void _addChildren(Content content, List<String> path, TreeConfiguration treeConfiguration, Map<String, Object> infos)
168    {
169        Map<String, List<Content>> children = getChildrenContent(content, treeConfiguration);
170        
171        boolean hasAtLeastOneAutoExpand = hasAutoExpandTargets(treeConfiguration);
172
173        List<Map<String, Object>> childrenInfos = new ArrayList<>();
174        infos.put("children", childrenInfos);
175
176        for (String attributePath : children.keySet())
177        {
178            for (Content childContent : children.get(attributePath))
179            {
180                boolean expand = hasAtLeastOneAutoExpand && !isAnAutoExpandTarget(treeConfiguration, childContent);
181                
182                List<String> contentPath = new ArrayList<>(path);
183                contentPath.add(childContent.getId());
184                
185                Map<String, Object> childInfo = content2Json(childContent, contentPath);
186                childInfo.put("metadataPath", attributePath);
187                childInfo.put("expanded", expand);
188                
189                if (expand)
190                {
191                    _addChildren(childContent, contentPath, treeConfiguration, childInfo);
192                }
193                
194                if (!hasChildrenContent(childContent, treeConfiguration))
195                {
196                    // childInfo.put("leaf", true);
197                    childInfo.put("children", Collections.EMPTY_LIST);
198                }
199                else
200                {
201                    childInfo.put("leaf", false);
202                    childInfo.put("isExpanded", false);
203                }
204
205                childrenInfos.add(childInfo);
206            }
207        }
208
209    }
210    
211    /**
212     * Get the path of children content which match filter regexp
213     * @param parentContentId The id of content to start search
214     * @param treeId The id of tree configuration
215     * @param value the value to match
216     * @return the matching paths composed by contents id separated by ';'
217     */
218    @Callable
219    public List<String> filterChildrenContentByRegExp(String parentContentId, String treeId, String value)
220    {
221        List<String> matchingPaths = new ArrayList<>();
222
223        Content parentContent = _ametysResolver.resolveById(parentContentId);
224        TreeConfiguration treeConfiguration = _treeExtensionPoint.getExtension(treeId);
225        
226        String toMatch = StringUtils.stripAccents(value.toLowerCase()).trim();
227        
228        Map<String, List<Content>> childrenContentByAttributes = getChildrenContent(parentContent, treeConfiguration);
229        for (List<Content> childrenContent : childrenContentByAttributes.values())
230        {
231            for (Content childContent : childrenContent)
232            {
233                _getMatchingContents(childContent, toMatch, treeConfiguration, matchingPaths, parentContentId);
234            }
235        }
236        
237        return matchingPaths;
238    }
239    
240    private void _getMatchingContents(Content content, String value, TreeConfiguration treeConfiguration, List<String> matchingPaths, String parentPath)
241    {
242        if (isContentMatching(content, value))
243        {
244            matchingPaths.add(parentPath + ";" + content.getId());
245        }
246        
247        Map<String, List<Content>> childrenContentByAttributes = getChildrenContent(content, treeConfiguration);
248        for (List<Content> childrenContent : childrenContentByAttributes.values())
249        {
250            for (Content childContent : childrenContent)
251            {
252                _getMatchingContents(childContent, value, treeConfiguration, matchingPaths, parentPath + ";" + content.getId());
253            }
254        }
255    }
256    
257    /**
258     * Determines if content matches the filter regexp
259     * @param content the content
260     * @param value the value to match
261     * @return true if the content match
262     */
263    protected boolean isContentMatching(Content content, String value)
264    {
265        String title =  StringUtils.stripAccents(content.getTitle().toLowerCase());
266        return title.contains(value);
267    }
268    
269    /**
270     * Get the root node informations
271     * @param contentId The content
272     * @param treeId The contents tree id
273     * @return The informations
274     */
275    @Callable
276    public Map<String, Object> getRootNodeInformations(String contentId, String treeId)
277    {
278        TreeConfiguration treeConfiguration = _getTreeConfiguration(treeId);
279        Content content = _ametysResolver.resolveById(contentId);
280        
281        Map<String, Object> nodeInformations =  content2Json(content, List.of(contentId));
282        nodeInformations.put("id", "root"); // Root id should not be random, for delete op
283        _addChildren(content, List.of(contentId), treeConfiguration, nodeInformations); // auto expand first level + recursively if necessary
284
285        return nodeInformations;
286    }
287    
288    /**
289     * Get the node informations
290     * @param contentId The content
291     * @param path The path of the content in the tree
292     * @return The informations
293     */
294    @Callable
295    public Map<String, Object> getNodeInformations(String contentId, List<String> path)
296    {
297        Content content = _ametysResolver.resolveById(contentId);
298        return content2Json(content, path);
299    }
300    
301    /**
302     * Get the default JSON representation of a content of the tree
303     * @param content the content
304     * @param path The path of the content in the tree
305     * @return the content as JSON
306     */
307    protected Map<String, Object> content2Json(Content content, List<String> path)
308    {
309        Map<String, Object> infos = new HashMap<>();
310        
311        infos.put("id", "random-id-" + org.ametys.core.util.StringUtils.generateKey() + "-" + Math.round(Math.random() * 10_000));
312        
313        infos.put("contentId", content.getId());
314        infos.put("contenttypesIds", content.getTypes());
315        infos.put("name", content.getName());
316        infos.put("title", content.getTitle());
317
318        infos.put("iconGlyph", _contentTypesHelper.getIconGlyph(content));
319        infos.put("iconDecorator", _contentTypesHelper.getIconDecorator(content));
320        infos.put("iconSmall", _contentTypesHelper.getSmallIcon(content));
321        infos.put("iconMedium", _contentTypesHelper.getMediumIcon(content));
322        infos.put("iconLarge", _contentTypesHelper.getLargeIcon(content));
323        
324        return infos;
325    }
326    
327    /**
328     * Get the default JSON representation of a child content
329     * @param content the content
330     * @param path The path of the content in the tree
331     * @param attributePath the path of attribute holding this content
332     * @return the content as JSON
333     */
334    public Map<String, Object> childContent2Json(Content content, List<String> path, String attributePath)
335    {
336        Map<String, Object> childInfo = content2Json(content, path);
337        childInfo.put("metadataPath", attributePath);
338        return childInfo;
339    }
340    
341    private void _merge(Map<String, List<Content>> childrenContent, Map<String, List<Content>> contents)
342    {
343        for (String key : contents.keySet())
344        {
345            if (!childrenContent.containsKey(key))
346            {
347                childrenContent.put(key, new ArrayList<>());
348            }
349            
350            List<Content> contentsList = childrenContent.get(key);
351            contentsList.addAll(contents.get(key));
352        }
353    }
354
355    private Map<String, List<Content>> _handleAttributeTreeConfigurationElementsChild(ContentType contentType, ModelAwareDataHolder dataHolder, AttributeTreeConfigurationElementsChild attributeTreeConfigurationElementsChild, TreeConfiguration treeConfiguration)
356    {
357        Map<String, List<Content>> childrenContent = new HashMap<>();
358        
359        String attributePath = attributeTreeConfigurationElementsChild.getPath();
360
361        try
362        {
363            Map<String, List<Content>> contents = _handleAttribute(contentType, dataHolder, attributePath);
364            _merge(childrenContent, contents);
365        }
366        catch (Exception e)
367        {
368            throw new IllegalArgumentException("An error occured on the tree configuration '" + treeConfiguration.getId() + "' getting for metadata '" + attributePath + "' on content type '" + contentType.getId() + "'", e);
369        }
370
371        return childrenContent;
372    }
373
374    private Map<String, List<Content>> _handleAttribute(ModelItemContainer modelItemContainer, ModelAwareDataHolder dataHolder, String attributePath)
375    {
376        Map<String, List<Content>> childrenContent = new HashMap<>();
377
378        String currentModelItemName = StringUtils.substringBefore(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
379        
380        ModelItem currentModelItem = modelItemContainer.getChild(currentModelItemName);
381        if (currentModelItem == null)
382        {
383            throw new IllegalArgumentException("No attribute definition for " + currentModelItemName);
384        }
385        
386        
387        if (dataHolder.hasValue(currentModelItemName))
388        {
389            if (currentModelItem instanceof RepeaterDefinition)
390            {
391                ModelAwareRepeater repeater = dataHolder.getRepeater(currentModelItemName);
392                for (ModelAwareRepeaterEntry entry : repeater.getEntries())
393                {
394                    String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
395                    Map<String, List<Content>> contents = _handleAttribute((RepeaterDefinition) currentModelItem, entry, subMetadataId);
396
397                    _merge(childrenContent, contents);
398                }
399            }
400            else if (currentModelItem instanceof CompositeDefinition)
401            {
402                ModelAwareComposite metadata = dataHolder.getComposite(currentModelItemName);
403                
404                String subMetadataId = StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
405                Map<String, List<Content>> contents = _handleAttribute((CompositeDefinition) currentModelItem, metadata, subMetadataId);
406
407                _merge(childrenContent, contents);
408            }
409            else if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(currentModelItem.getType().getId()))
410            {
411                ContentValue[] contentValues = dataHolder.getValue(currentModelItemName);
412                for (ContentValue contentValue : contentValues)
413                {
414                    String key = currentModelItem.getPath();
415                    Optional<ModifiableContent> optContent = contentValue.getContentIfExists();
416                    if (optContent.isPresent())
417                    {
418                        childrenContent.computeIfAbsent(key, k -> new ArrayList<>()).add(optContent.get());
419                    }
420                    else
421                    {
422                        getLogger().warn("On a data holder, the attribute '{}' is referencing a unexisting content: '{}'", key, contentValue.getContentId());
423                    }
424                }
425            }
426            else
427            {
428                throw new IllegalArgumentException("The metadata definition for " + currentModelItem.getPath() + " is not a content");
429            }
430        }
431        
432        return childrenContent;
433    }
434    
435    /**
436     * Get the tree configuration
437     * @param treeId the tree id
438     * @return the tree configuration
439     */
440    protected TreeConfiguration _getTreeConfiguration(String treeId)
441    {
442        if (StringUtils.isBlank(treeId))
443        {
444            throw new IllegalArgumentException("The tree information cannot be obtain, because 'tree' is null");
445        }
446
447        TreeConfiguration treeConfiguration = _treeExtensionPoint.getExtension(treeId);
448        if (treeConfiguration == null)
449        {
450            throw new IllegalArgumentException("There is no tree configuration for '" + treeId + "'");
451        }
452        return treeConfiguration;
453    }
454
455    /**
456     * Get the parent content of a tree
457     * @param parentId the parent id
458     * @return the parent content of a tree
459     * @throws IllegalArgumentException if an exception occurred
460     */
461    protected Content _getParentContent(String parentId) throws IllegalArgumentException
462    {
463        if (StringUtils.isBlank(parentId))
464        {
465            throw new IllegalArgumentException("The tree information cannot be obtain, because 'node' is null");
466        }
467        
468        try
469        {
470            return _ametysResolver.resolveById(parentId);
471        }
472        catch (Exception e)
473        {
474            throw new IllegalArgumentException("The tree configuration cannot be used on an object that is not a content: " + parentId, e);
475        }
476
477    }
478
479    /**
480     * Should auto expand until some kind of node?
481     * @param treeConfiguration The tree configuration
482     * @return true if should auto expand
483     */
484    protected boolean hasAutoExpandTargets(TreeConfiguration treeConfiguration)
485    {
486        return treeConfiguration.getElements()
487            .stream()
488            .map(TreeConfigurationElements::getContentTypesConfiguration)
489            .flatMap(Collection::stream)
490            .anyMatch(TreeConfigurationContentType::autoExpandTarget);
491    }
492    
493    /**
494     * Should auto expand until some kind of node?
495     * @param treeConfiguration The tree configuration
496     * @param content The content involved
497     * @return true if should auto expand to it
498     */
499    protected boolean isAnAutoExpandTarget(TreeConfiguration treeConfiguration, Content content)
500    {
501        List<String> contentTypes = Arrays.asList(content.getTypes());
502        
503        return treeConfiguration.getElements()
504                .stream()
505                .map(TreeConfigurationElements::getContentTypesConfiguration)
506                .flatMap(Collection::stream)
507                .filter(TreeConfigurationContentType::autoExpandTarget)
508                .map(TreeConfigurationContentType::getContentTypesIds)
509                .anyMatch(ct -> { Set<String> hs = new HashSet<>(ct); hs.retainAll(contentTypes); return hs.size() > 0; });
510    }
511}