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