001/*
002 *  Copyright 2016 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.userdirectory;
017
018import java.text.Normalizer;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Set;
027import java.util.SortedSet;
028import java.util.TreeSet;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031import java.util.stream.Collectors;
032
033import org.apache.avalon.framework.activity.Initializable;
034import org.apache.avalon.framework.component.Component;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.commons.lang3.StringUtils;
039
040import org.ametys.cms.contenttype.ContentType;
041import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ContentTypeExpression;
044import org.ametys.cms.repository.LanguageExpression;
045import org.ametys.plugins.repository.AmetysObjectIterable;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.AmetysRepositoryException;
048import org.ametys.plugins.repository.UnknownAmetysObjectException;
049import org.ametys.plugins.repository.provider.WorkspaceSelector;
050import org.ametys.plugins.repository.query.QueryHelper;
051import org.ametys.plugins.repository.query.SortCriteria;
052import org.ametys.plugins.repository.query.expression.AndExpression;
053import org.ametys.plugins.repository.query.expression.Expression;
054import org.ametys.plugins.repository.query.expression.Expression.Operator;
055import org.ametys.plugins.repository.query.expression.OrExpression;
056import org.ametys.plugins.repository.query.expression.StringExpression;
057import org.ametys.plugins.repository.query.expression.VirtualFactoryExpression;
058import org.ametys.plugins.userdirectory.page.VirtualUserDirectoryPageFactory;
059import org.ametys.runtime.plugin.component.AbstractLogEnabled;
060import org.ametys.web.repository.page.Page;
061import org.ametys.web.repository.page.PageQueryHelper;
062
063/**
064 * Component providing methods to retrieve user directory virtual pages, such as the user directory root,
065 * transitional page and user page.
066 */
067public class UserDirectoryPageHandler extends AbstractLogEnabled implements Component, Serviceable, Initializable
068{
069    /** The avalon role. */
070    public static final String ROLE = UserDirectoryPageHandler.class.getName();
071    
072    /** The attribute name for the content type of the user directory */
073    public static final String CONTENT_TYPE_METADATA_NAME = "user-directory-root-contenttype";
074    /** The metadata name for the classification metadata of the user directory */
075    public static final String CLASSIFICATION_METADATA_METADATA_NAME = "user-directory-root-classification-metadata";
076    /** The metadata name for the depth of the user directory */
077    public static final String DEPTH_METADATA_NAME = "user-directory-root-depth";
078    /** The parent content type id */
079    public static final String ABSTRACT_USER_CONTENT_TYPE = "org.ametys.plugins.userdirectory.Content.user";
080    
081    /** The workspace selector. */
082    protected WorkspaceSelector _workspaceSelector;
083    /** The ametys object resolver. */
084    protected AmetysObjectResolver _resolver;
085    /** The extension point for content types */
086    protected ContentTypeExtensionPoint _contentTypeEP;
087    
088    /** The user directory root pages, indexed by workspace, site and sitemap name. */
089    protected Map<String, Map<String, Map<String, Set<String>>>> _userDirectoryRootPages;
090    /** The list of transitional page workspace --&gt; contentType --&gt; (key : page name -- list&lt;&gt; children pages names)  */
091    protected Map<String, Map<String, Map<String, SortedSet<String>>>> _transitionalPagesCache;
092    /** The list of user page  workspace --&gt; contentType --&gt; (key : page name --&gt; Map(page name, content id)) */
093    protected Map<String, Map<String, Map<String, Map<String, String>>>> _userPagesCache;
094    
095    @Override
096    public void service(ServiceManager manager) throws ServiceException
097    {
098        _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE);
099        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
100        _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
101    }
102    
103    @Override
104    public void initialize() throws Exception
105    {
106        _userDirectoryRootPages = new HashMap<>();
107        _transitionalPagesCache = new HashMap<>();
108        _userPagesCache = new HashMap<>();
109    }
110    
111    /**
112     * Gets the user directory root pages from the given content type id, whatever the site.
113     * @param contentTypeId The content type id
114     * @return the user directory root pages.
115     * @throws AmetysRepositoryException  if an error occured.
116     */
117    public Set<Page> getUserDirectoryRootPages(String contentTypeId) throws AmetysRepositoryException
118    {
119        Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName());
120        Expression contentTypeExp = new StringExpression(CONTENT_TYPE_METADATA_NAME, Operator.EQ, contentTypeId);
121        
122        AndExpression andExp = new AndExpression(expression, contentTypeExp);
123        
124        String query = PageQueryHelper.getPageXPathQuery(null, null, null, andExp, null);
125        
126        AmetysObjectIterable<Page> pages = _resolver.query(query);
127        
128        return pages.stream().collect(Collectors.toSet());
129    }
130    
131    /**
132     * Gets the user directory root page of a specific content type.
133     * @param siteName The site name
134     * @param sitemapName The sitemap
135     * @param contentTypeId The content type id
136     * @return the user directory root pages.
137     * @throws AmetysRepositoryException  if an error occured.
138     */
139    public Page getUserDirectoryRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException
140    {
141        String contentTypeIdToCompare = contentTypeId != null ? contentTypeId : "";
142        
143        for (Page userDirectoryRootPage : getUserDirectoryRootPages(siteName, sitemapName))
144        {
145            if (contentTypeIdToCompare.equals(getContentTypeId(userDirectoryRootPage)))
146            {
147                return userDirectoryRootPage;
148            }
149        }
150        
151        return null;
152    }
153    
154    /**
155     * Gets the user directory root pages.
156     * @param siteName The site name
157     * @param sitemapName The sitemap
158     * @return the user directory root pages.
159     * @throws AmetysRepositoryException  if an error occured.
160     */
161    public Set<Page> getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException
162    {
163        Set<Page> rootPages = new HashSet<>();
164        
165        String workspace = _workspaceSelector.getWorkspace();
166        
167        Map<String, Map<String, Set<String>>> wspUserDirectoryRootPages;
168        if (_userDirectoryRootPages.containsKey(workspace))
169        {
170            wspUserDirectoryRootPages = _userDirectoryRootPages.get(workspace);
171        }
172        else
173        {
174            wspUserDirectoryRootPages = new HashMap<>();
175            _userDirectoryRootPages.put(workspace, wspUserDirectoryRootPages);
176        }
177        
178        Map<String, Set<String>> siteUserDirectoryRootPages;
179        if (wspUserDirectoryRootPages.containsKey(siteName))
180        {
181            siteUserDirectoryRootPages = wspUserDirectoryRootPages.get(siteName);
182        }
183        else
184        {
185            siteUserDirectoryRootPages = new HashMap<>();
186            wspUserDirectoryRootPages.put(siteName, siteUserDirectoryRootPages);
187        }
188        
189        if (siteUserDirectoryRootPages.containsKey(sitemapName))
190        {
191            Set<String> userDirectoryRootPageIds = siteUserDirectoryRootPages.get(sitemapName);
192            if (userDirectoryRootPageIds != null)
193            {
194                rootPages = userDirectoryRootPageIds.stream()
195                        .map(this::_resolvePage)
196                        .filter(Objects::nonNull)
197                        .collect(Collectors.toSet());
198            }
199        }
200        else
201        {
202            rootPages = _getUserDirectoryRootPages(siteName, sitemapName);
203            Set<String> userDirectoryRootPageIds = rootPages.stream()
204                    .map(Page::getId)
205                    .collect(Collectors.toSet());
206            siteUserDirectoryRootPages.put(sitemapName, userDirectoryRootPageIds);
207        }
208        
209        return rootPages;
210    }
211    
212    private Page _resolvePage(String pageId)
213    {
214        try
215        {
216            return _resolver.resolveById(pageId);
217        }
218        catch (UnknownAmetysObjectException e)
219        {
220            // The page stored in cache may be deleted
221            return null;
222        }
223    }
224    
225    /**
226     * Get the user directory root pages, without searching in the cache.
227     * @param siteName the current site.
228     * @param sitemapName the sitemap name.
229     * @return the user directory root pages
230     * @throws AmetysRepositoryException if an error occured.
231     */
232    protected Set<Page> _getUserDirectoryRootPages(String siteName, String sitemapName) throws AmetysRepositoryException
233    {
234        Expression expression = new VirtualFactoryExpression(VirtualUserDirectoryPageFactory.class.getName());
235        
236        String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null);
237        
238        AmetysObjectIterable<Page> pages = _resolver.query(query);
239        
240        return pages.stream().collect(Collectors.toSet());
241    }
242    
243    /**
244     * Gets the depth of the user directory root page
245     * @param rootPage The user directory root page
246     * @return the depth of the user directory root page
247     */
248    public int getDepth(Page rootPage)
249    {
250        return Math.toIntExact(rootPage.getValue(DEPTH_METADATA_NAME));
251    }
252    
253    /**
254     * Gets the name of the classification metadata
255     * @param rootPage The user directory root page
256     * @return the name of the classification metadata
257     */
258    public String getClassificationAttribute(Page rootPage)
259    {
260        return rootPage.getValue(CLASSIFICATION_METADATA_METADATA_NAME);
261    }
262    
263    /**
264     * Gets the content type id
265     * @param rootPage The user directory root page
266     * @return the content type id
267     */
268    public String getContentTypeId(Page rootPage)
269    {
270        return rootPage.getValue(CONTENT_TYPE_METADATA_NAME);
271    }
272    
273    /**
274     * Gets the content type
275     * @param rootPage The user directory root page
276     * @return the content type
277     */
278    public ContentType getContentType(Page rootPage)
279    {
280        String contentTypeId = getContentTypeId(rootPage);
281        return StringUtils.isNotBlank(contentTypeId) ? _contentTypeEP.getExtension(contentTypeId) : null;
282    }
283    
284    /**
285     * Gets the value of the classification attribute for the given content, transformed for building tree hierarchy
286     * <br>The transformation takes the lower-case of all characters, removes non-alphanumeric characters,
287     * and takes the first characters to not have a string with a size bigger than the depth
288     * <br>For instance, if the value for the content is "Aéa Foo-bar" and the depth is 7,
289     * then this method will return "aeafoob"
290     * @param rootPage The user directory root page
291     * @param content The content
292     * @return the transformed value of the classification attribute for the given content. Can be null
293     */
294    public String getTransformedClassificationValue(Page rootPage, Content content)
295    {
296        String attribute = getClassificationAttribute(rootPage);
297        int depth = getDepth(rootPage);
298        
299        // 1) get value of the classification attribute
300        String classification = content.getValue(attribute);
301        
302        if (classification == null)
303        {
304            // The classification does not exists for the content
305            getLogger().info("The classification attribute '{}' does not exist for the content {}", attribute, content);
306            return null;
307        }
308        
309        try
310        {
311            // 2) replace special character
312            // 3) remove '-' characters
313            
314            // FIXME CMS-5758 FilterNameHelper.filterName do not authorized name with numbers only.
315            // So code of FilterNamehelper is temporarily duplicated here with a slightly modified RegExp
316//            String transformedValue = FilterNameHelper.filterName(classification).replace("-", "");
317            String transformedValue = _filterName(classification).replace("-", "");
318            
319            // 4) only keep 'depth' first characters (if depth = 3, "de" becomes "de", "debu" becomes "deb", etc.)
320            return StringUtils.substring(transformedValue, 0, depth);
321        }
322        catch (IllegalArgumentException e)
323        {
324            // The value of the classification attribute is not valid
325            getLogger().warn("The classification attribute '{}' does not have a valid value ({}) for the content {}", attribute, classification, content);
326            return null;
327        }
328    }
329    
330    private String _filterName(String name)
331    {
332        Pattern pattern = Pattern.compile("^()[0-9-_]*[a-z0-9].*$");
333        // Use lower case
334        // then remove accents
335        // then replace contiguous spaces with one dash
336        // and finally remove non-alphanumeric characters except -
337        String filteredName = Normalizer.normalize(name.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 
338        filteredName = filteredName.replaceAll("œ", "oe").replaceAll("æ", "ae").replaceAll(" +", "-").replaceAll("[^\\w-]", "-").replaceAll("-+", "-");
339
340        Matcher m = pattern.matcher(filteredName);
341        if (!m.matches())
342        {
343            throw new IllegalArgumentException(filteredName + " doesn't match the expected regular expression : " + pattern.pattern());
344        }
345
346        filteredName = filteredName.substring(m.end(1));
347
348        // Remove characters '-' and '_' at the start and the end of the string
349        return StringUtils.strip(filteredName, "-_");
350    }
351    
352    /**
353     * Get all transitional page child from page name
354     * @param rootPage the root page
355     * @param pagePath the page path
356     * @return all transitional page child from page name
357     */
358    public SortedSet<String> getTransitionalPagesName(Page rootPage, String pagePath)
359    {
360        String cachePagePath = getName(pagePath);
361        
362        String workspace = _workspaceSelector.getWorkspace();
363        String contentType = getContentTypeId(rootPage);
364        _initializeCaches(rootPage, workspace, contentType);
365        Map<String, SortedSet<String>> transitionalPageCache = _transitionalPagesCache.get(workspace).get(contentType);
366        
367        return transitionalPageCache.containsKey(cachePagePath) ? transitionalPageCache.get(cachePagePath) : new TreeSet<>();
368    }
369    
370    private void _initializeCaches(Page rootPage, String workspace, String contentType)
371    {
372        // Get transitional page cache
373        if (!_transitionalPagesCache.containsKey(workspace))
374        {
375            _transitionalPagesCache.put(workspace, new HashMap<>());
376        }
377        Map<String, Map<String, SortedSet<String>>> transitionalPageCacheByContenttype = _transitionalPagesCache.get(workspace);
378        
379        // Get user page cache
380        if (!_userPagesCache.containsKey(workspace))
381        {
382            _userPagesCache.put(workspace, new HashMap<>());
383        }
384        Map<String, Map<String, Map<String, String>>> userPageCacheByContenttype = _userPagesCache.get(workspace);
385        
386        if (transitionalPageCacheByContenttype.containsKey(contentType) && userPageCacheByContenttype.containsKey(contentType))
387        {
388            // Both caches are initialized
389            getLogger().debug("TransitionalPageCache and UserPageCache are initialized for workspace '{}' and content type '{}'", workspace, contentType);
390            return;
391        }
392        
393        // Get all contents which will appear in the sitemap
394        AmetysObjectIterable<Content> contents = _getContentsForRootPage(rootPage);
395        
396        // Get their classification attribute value
397        Map<Content, String> transformedValuesByContent = new LinkedHashMap<>();
398        for (Content content : contents)
399        {
400            String value = getTransformedClassificationValue(rootPage, content);
401            if (value != null)
402            {
403                transformedValuesByContent.put(content, value);
404            }
405        }
406        
407        if (!transitionalPageCacheByContenttype.containsKey(contentType))
408        {
409            Map<String, SortedSet<String>> transitionalPageCache = new HashMap<>();
410            transitionalPageCacheByContenttype.put(contentType, transitionalPageCache);
411            
412            Set<String> transformedValues = new HashSet<>(transformedValuesByContent.values());
413            _buildTransitionalPageCache(transformedValues, transitionalPageCache);
414            getLogger().info("Transitional page cache was built for workspace '{}' and content type '{}'\n It is equal to: {}", workspace, contentType, transitionalPageCache);
415        }
416        
417        if (!userPageCacheByContenttype.containsKey(contentType))
418        {
419            Map<String, Map<String, String>> userPageCache = new HashMap<>();
420            userPageCacheByContenttype.put(contentType, userPageCache);
421            
422            int depth = getDepth(rootPage);
423            _buildUserPageCache(transformedValuesByContent, depth, userPageCache);
424            getLogger().info("User page cache was built for workspace '{}' and content type '{}'\n It is equal to: {}", workspace, contentType, userPageCache);
425        }
426    }
427    
428    private void _buildTransitionalPageCache(Set<String> transformedValues, Map<String, SortedSet<String>> transitionalPageCache)
429    {
430        for (String value : transformedValues)
431        {
432            char[] charArray = value.toCharArray();
433            for (int i = 0; i < charArray.length; i++)
434            {
435                String lastChar = String.valueOf(charArray[i]);
436                if (i == 0)
437                {
438                    // case _root
439                    if (!transitionalPageCache.containsKey("_root"))
440                    {
441                        transitionalPageCache.put("_root", new TreeSet<>());
442                    }
443                    Set<String> root = transitionalPageCache.get("_root");
444                    if (!root.contains(lastChar))
445                    {
446                        root.add(lastChar);
447                    }
448                }
449                else
450                {
451                    String currentPrefixWithoutLastChar = value.substring(0, i); // if value == "debu", equals to "deb"
452                    String currentPathWithoutLastChar = StringUtils.join(currentPrefixWithoutLastChar.toCharArray(), '/'); // if value == "debu", equals to "d/e/b"
453                    if (!transitionalPageCache.containsKey(currentPathWithoutLastChar))
454                    {
455                        transitionalPageCache.put(currentPathWithoutLastChar, new TreeSet<>());
456                    }
457                    Set<String> childPageNames = transitionalPageCache.get(currentPathWithoutLastChar);
458                    
459                    if (!childPageNames.contains(lastChar))
460                    {
461                        childPageNames.add(lastChar); // if value == "debu", add "u" in childPageNames for key "d/e/b"
462                    }
463                }
464            }
465        }
466    }
467    
468    private void _buildUserPageCache(Map<Content, String> transformedValuesByContent, int depth, Map<String, Map<String, String>> userPageCache)
469    {
470        if (depth == 0)
471        {
472            Map<String, String> rootContents = new LinkedHashMap<>();
473            for (Content content : transformedValuesByContent.keySet())
474            {
475                rootContents.put(content.getName(), content.getId());
476            }
477            userPageCache.put("_root", rootContents);
478            return;
479        }
480        
481        for (Content content : transformedValuesByContent.keySet())
482        {
483            String transformedValue = transformedValuesByContent.get(content);
484            for (int i = 0; i < depth; i++)
485            {
486                String currentPrefix = StringUtils.substring(transformedValue, 0, i + 1);
487                String currentPath = StringUtils.join(currentPrefix.toCharArray(), '/');
488                if (!userPageCache.containsKey(currentPath))
489                {
490                    userPageCache.put(currentPath, new LinkedHashMap<>());
491                }
492                Map<String, String> contentsForPath = userPageCache.get(currentPath);
493                
494                String contentName = content.getName();
495                if (!contentsForPath.containsKey(contentName))
496                {
497                    contentsForPath.put(contentName, content.getId());
498                }
499            }
500        }
501    }
502    
503    /**
504     * Get all user page child from page name
505     * @param rootPage the root page
506     * @param pagePath the page path
507     * @return all user page child from page name
508     */
509    public Map<String, String> getUserPagesContent(Page rootPage, String pagePath)
510    {
511        String cachePagePath = getName(pagePath);
512        
513        String workspace = _workspaceSelector.getWorkspace();
514        String contentType = getContentTypeId(rootPage);
515        _initializeCaches(rootPage, workspace, contentType);
516        Map<String, Map<String, String>> userPageCache = _userPagesCache.get(workspace).get(contentType);
517        
518        return userPageCache.containsKey(cachePagePath) ? userPageCache.get(cachePagePath) : new HashMap<>();
519    }
520    
521    private AmetysObjectIterable<Content> _getContentsForRootPage(Page rootPage)
522    {
523        String contentType = getContentTypeId(rootPage);
524        String lang = rootPage.getSitemapName();
525        
526        Set<String> subTypes = _contentTypeEP.getSubTypes(contentType);
527        
528        List<Expression> contentTypeExpressions = new ArrayList<>();
529        contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, contentType));
530        for (String subType : subTypes)
531        {
532            contentTypeExpressions.add(new ContentTypeExpression(Operator.EQ, subType));
533        }
534        
535        Expression contentTypeExpression = new OrExpression(contentTypeExpressions.toArray(new Expression[subTypes.size() + 1]));
536        
537        Expression finalExpr = new AndExpression(contentTypeExpression, new LanguageExpression(Operator.EQ, lang));
538        
539        SortCriteria sort = new SortCriteria();
540        sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
541        
542        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort);
543        
544        return _resolver.query(xPathQuery);
545    }
546    
547    /**
548     * Gets name form path name
549     * @param pathName the path name
550     * @return the name
551     */
552    public String getName(String pathName)
553    {
554        String prefix = "page-";
555        String name = "";
556        for (String transitionalPageName : pathName.split("/"))
557        {
558            if (!name.equals(""))
559            {
560                name += "/";
561            }
562            name += StringUtils.startsWith(transitionalPageName, prefix) ? StringUtils.substringAfter(transitionalPageName, prefix) : transitionalPageName;
563        }
564        return name;
565    }
566    
567    /**
568     * Checks if name contains only Unicode digits and if so, prefix it with "page-"
569     * @param name The page name
570     * @return The potentially prefixed page name
571     */
572    public String getPathName(String name)
573    {
574        return StringUtils.isNumeric(name) ? "page-" + name : name; 
575    }
576    
577    /**
578     * Clear root page cache
579     * @param rootPage the root page
580     */
581    public void clearCache(Page rootPage)
582    {
583        clearCache(getContentTypeId(rootPage));
584    }
585    
586    /**
587     * Clear root page cache
588     * @param contentTypeId the content type id
589     */
590    public void clearCache(String contentTypeId)
591    {
592        // Clear cache for all workspaces
593        for (String workspaceName : _transitionalPagesCache.keySet())
594        {
595            Map<String, Map<String, SortedSet<String>>> contentTypeIdsMap = _transitionalPagesCache.get(workspaceName);
596            contentTypeIdsMap.remove(contentTypeId);
597        }
598        
599        for (String workspaceName : _userPagesCache.keySet())
600        {
601            Map<String, Map<String, Map<String, String>>> contentTypeIdsMap = _userPagesCache.get(workspaceName);
602            contentTypeIdsMap.remove(contentTypeId);
603        }
604        
605        _userDirectoryRootPages = new HashMap<>();
606    }
607}