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