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}