001/*
002 *  Copyright 2018 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.ugc.page;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Optional;
025import java.util.Set;
026import java.util.stream.Collectors;
027
028import javax.jcr.Node;
029import javax.jcr.RepositoryException;
030import javax.jcr.Value;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.context.Context;
034import org.apache.avalon.framework.context.ContextException;
035import org.apache.avalon.framework.context.Contextualizable;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.avalon.framework.service.Serviceable;
039import org.apache.cocoon.components.ContextHelper;
040import org.apache.cocoon.environment.Request;
041import org.apache.commons.lang.StringUtils;
042import org.apache.jackrabbit.value.StringValue;
043import org.slf4j.Logger;
044
045import org.ametys.cms.contenttype.ContentAttributeDefinition;
046import org.ametys.cms.contenttype.ContentType;
047import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
048import org.ametys.cms.contenttype.ContentTypesHelper;
049import org.ametys.cms.data.ContentValue;
050import org.ametys.cms.repository.Content;
051import org.ametys.cms.repository.ContentTypeExpression;
052import org.ametys.cms.repository.LanguageExpression;
053import org.ametys.core.observation.Event;
054import org.ametys.core.observation.ObservationManager;
055import org.ametys.core.user.CurrentUserProvider;
056import org.ametys.core.util.I18nUtils;
057import org.ametys.core.util.LambdaUtils;
058import org.ametys.plugins.repository.AmetysObjectIterable;
059import org.ametys.plugins.repository.AmetysObjectResolver;
060import org.ametys.plugins.repository.AmetysRepositoryException;
061import org.ametys.plugins.repository.UnknownAmetysObjectException;
062import org.ametys.plugins.repository.jcr.JCRAmetysObject;
063import org.ametys.plugins.repository.query.QueryHelper;
064import org.ametys.plugins.repository.query.SortCriteria;
065import org.ametys.plugins.repository.query.expression.AndExpression;
066import org.ametys.plugins.repository.query.expression.Expression;
067import org.ametys.plugins.repository.query.expression.Expression.Operator;
068import org.ametys.plugins.ugc.observation.ObservationConstants;
069import org.ametys.plugins.repository.query.expression.MetadataExpression;
070import org.ametys.plugins.repository.query.expression.NotExpression;
071import org.ametys.plugins.repository.query.expression.OrExpression;
072import org.ametys.plugins.repository.query.expression.StringExpression;
073import org.ametys.plugins.repository.query.expression.VirtualFactoryExpression;
074import org.ametys.runtime.model.ElementDefinition;
075import org.ametys.runtime.model.Enumerator;
076import org.ametys.runtime.model.ModelItem;
077import org.ametys.runtime.plugin.component.AbstractLogEnabled;
078import org.ametys.web.WebConstants;
079import org.ametys.web.WebHelper;
080import org.ametys.web.repository.SiteAwareAmetysObject;
081import org.ametys.web.repository.page.ModifiablePage;
082import org.ametys.web.repository.page.Page;
083import org.ametys.web.repository.page.PageQueryHelper;
084import org.ametys.web.repository.page.jcr.DefaultPage;
085
086/**
087 * Component providing methods to retrieve ugc virtual pages, such as the ugc root,
088 * transitional page and ugc content page.
089 */
090public class UGCPageHandler extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
091{
092    /** The attribute to get the name of transitional page */
093    public static final String ATTRIBUTE_TRANSITIONAL_PAGE_METADATA_VALUE = "metadata_value";
094    
095    /** The attribute to get the title of transitional page */
096    public static final String ATTRIBUTE_TRANSITIONAL_PAGE_TITLE = "title";
097    
098    /** The avalon role. */
099    public static final String ROLE = UGCPageHandler.class.getName();
100    
101    /** The data name for the content type of the ugc */
102    public static final String CONTENT_TYPE_DATA_NAME = "ugc-root-contenttype";
103    
104    /** The data name for the classification attribute of the ugc */
105    public static final String CLASSIFICATION_ATTRIBUTE_DATA_NAME = "ugc-root-classification-metadata";
106    
107    /** The data name for the visibility of transitional page of the ugc */
108    public static final String CLASSIFICATION_PAGE_VISIBLE_DATA_NAME = "ugc-root-classification-page-visible";
109    
110    /** The ametys object resolver */
111    protected AmetysObjectResolver _resolver;
112    
113    /** The content type extension point */
114    protected ContentTypeExtensionPoint _cTypeEP;
115    
116    /** The content types helper */
117    protected ContentTypesHelper _cTypeHelper;
118    
119    /** The i18n utils */
120    protected I18nUtils _i18nUtils;
121
122    /** The avalon context */
123    protected Context _context;
124
125    /** Observer manager. */
126    protected ObservationManager _observationManager;
127    
128    /** Current user provider */
129    protected CurrentUserProvider _currentUserProvider;
130    
131    @Override
132    public void service(ServiceManager manager) throws ServiceException
133    {
134        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
135        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
136        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
137        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
138        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
139        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
140    }
141    
142    public void contextualize(Context context) throws ContextException
143    {
144        _context = context;
145    }
146    
147    @Override
148    protected Logger getLogger()
149    {
150        return super.getLogger();
151    }
152    
153    /**
154     * Gets the path of the classification attribute
155     * @param rootPage The ugc root page
156     * @return the path of the classification attribute
157     */
158    public String getClassificationAttribute(Page rootPage)
159    {
160        return rootPage.getValue(CLASSIFICATION_ATTRIBUTE_DATA_NAME);
161    }
162    
163    /**
164     * Gets the content type id
165     * @param rootPage The ugc root page
166     * @return the content type id
167     */
168    public String getContentTypeId(Page rootPage)
169    {
170        return rootPage.getValue(CONTENT_TYPE_DATA_NAME);
171    }
172    
173    /**
174     * <code>true</code> if the classification pages are visible
175     * @param rootPage The ugc root page
176     * @return <code>true</code> if the classification pages are visible
177     */
178    public boolean isClassificationPagesVisible(Page rootPage)
179    {
180        return rootPage.getValue(CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, false);
181    }
182    
183    /**
184     * True if the page a UGC root page for the given content type
185     * @param page the page
186     * @param contentTypeId The id of content type. Cannot be null.
187     * @return true if the page is a UGC root page for the given content type
188     */
189    public boolean isUGCRootPage(DefaultPage page, String contentTypeId)
190    {
191        try
192        {
193            Node node = page.getNode();
194            
195            if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY))
196            {
197                List<Value> values = Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues());
198                
199                boolean isUGCRootPage = values.stream()
200                        .map(LambdaUtils.wrap(Value::getString))
201                        .anyMatch(v -> VirtualUGCPageFactory.class.getName().equals(v));
202                
203                if (isUGCRootPage)
204                {
205                    return contentTypeId.equals(page.getValue(CONTENT_TYPE_DATA_NAME));
206                }
207            }
208        }
209        catch (RepositoryException e)
210        {
211            getLogger().warn("Unable to determine if page '" + page.getId() + "' is a UGC root page", e);
212        }
213        
214        return false;
215    }
216    
217    /**
218     * Gets the ugc root pages from the given content type id.
219     * @param siteName the site name
220     * @param sitemapName the sitemap name
221     * @param contentTypeId The content type id
222     * @return the ugc root page.
223     * @throws AmetysRepositoryException  if an error occured.
224     */
225    public Page getUGCRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException
226    {
227        Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName());
228        Expression contentTypeExp = new StringExpression(CONTENT_TYPE_DATA_NAME, Operator.EQ, contentTypeId);
229        
230        AndExpression andExp = new AndExpression(expression, contentTypeExp);
231        
232        String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, andExp, null);
233        
234        AmetysObjectIterable<Page> pages = _resolver.query(query);
235        
236        return pages.iterator().hasNext() ? pages.iterator().next() : null;
237    }
238    
239    /**
240     * Get the ugc root pages
241     * @param siteName the current site.
242     * @param sitemapName the sitemap name.
243     * @return the ugc root pages
244     * @throws AmetysRepositoryException if an error occured.
245     */
246    public Set<Page> getUGCRootPages(String siteName, String sitemapName) throws AmetysRepositoryException
247    {
248        Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName());
249        
250        String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null);
251        
252        AmetysObjectIterable<Page> pages = _resolver.query(query);
253        
254        return pages.stream().collect(Collectors.toSet());
255    }
256    
257    /**
258     * Set the ugc root page
259     * @param page the page to set as root
260     * @param contentTypeId the type of content root
261     * @param attributePath path to classification attribute
262     * @param classificationPageVisible true to show classification
263     * @throws RepositoryException if a repository error occurred
264     */
265    public void setUGCRoot(Page page, String contentTypeId, String attributePath, boolean classificationPageVisible) throws RepositoryException
266    {
267        Page currentUGCPage = getUGCRootPage(page.getSiteName(), page.getSitemapName(), contentTypeId);
268        
269        Map<String, Object> eventParams = new HashMap<>();
270        eventParams.put(org.ametys.web.ObservationConstants.ARGS_PAGE, page);
271        
272        if (currentUGCPage != null && currentUGCPage.getId().equals(page.getId()))
273        {
274            // Unindex pages for all workspaces before the properties changed 
275            _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATING, _currentUserProvider.getUser(), eventParams));
276            
277            _updateUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible);
278        }
279        else
280        {
281            _addUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible);
282        }
283        
284        // Live synchronization
285        _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams));
286        
287        // Indexation
288        _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATED, _currentUserProvider.getUser(), eventParams));
289    }
290    
291    private void _addUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible) throws RepositoryException
292    {
293        if (page instanceof JCRAmetysObject)
294        {
295            JCRAmetysObject jcrPage = (JCRAmetysObject) page;
296            Node node = jcrPage.getNode();
297            
298            List<Value> values = new ArrayList<>();
299            if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY))
300            {
301                values.addAll(Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues()));
302            }
303            
304            StringValue virtualUGCPageFactoryClassName = new StringValue(VirtualUGCPageFactory.class.getName());
305            if (!values.contains(virtualUGCPageFactoryClassName))
306            {
307                values.add(virtualUGCPageFactoryClassName);
308            }
309            
310            node.setProperty(AmetysObjectResolver.VIRTUAL_PROPERTY, values.toArray(new Value[values.size()]));
311
312            // Set the ugc root property
313            if (page instanceof ModifiablePage)
314            {
315                ((ModifiablePage) page).setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType);
316                ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata);
317                ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible);
318            }
319            
320            jcrPage.saveChanges();
321        }
322    }
323    
324    private void _updateUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible)
325    {
326        if (page instanceof ModifiablePage)
327        {
328            ModifiablePage modifiablePage = (ModifiablePage) page;
329            
330            // Set the ugc root property
331            modifiablePage.setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType);
332            modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata);
333            modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible);
334            
335            modifiablePage.saveChanges();
336        }
337    }
338    
339    /**
340     * Get UGC contents from rootPage
341     * @param rootPage the root page
342     * @return the list of UGC contents
343     */
344    public AmetysObjectIterable<Content> getContentsForRootPage(Page rootPage)
345    {
346        String lang = rootPage.getSitemapName();
347        String contentType = getContentTypeId(rootPage);
348        
349        ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType);
350        
351        StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName());
352        Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE));
353        Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr);
354        
355        Expression finalExpr = new AndExpression(contentTypeExp, new LanguageExpression(Operator.EQ, lang), fullSiteExpr);
356        
357        SortCriteria sort = new SortCriteria();
358        sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
359        
360        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort);
361        
362        return _resolver.query(xPathQuery);
363    }
364    
365    /**
366     * Determines if content is part of UGC root page
367     * @param rootPage The root page
368     * @param content the content
369     * @return true if content is part of UGC root page
370     */
371    public boolean hasContentForRootPage(Page rootPage, Content content)
372    {
373        String contentType = getContentTypeId(rootPage);
374        String siteName = rootPage.getSiteName();
375        String classificationMetadata = getClassificationAttribute(rootPage);
376        
377        return _cTypeHelper.isInstanceOf(content, contentType) // match content type
378                && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site
379                && StringUtils.isBlank(classificationMetadata); // no classification attribute
380    }
381    
382    /**
383     * Get the map of transitional page (name : (id, title))
384     * @param rootPage the root page
385     * @return The map of transitional page
386     */
387    public Map<String, Map<String, String>> getTransitionalPage(Page rootPage)
388    {
389        return _getClassificationType(rootPage)
390                .allTransitionalPages()
391                .stream()
392                .sorted(Comparator.comparing(TransitionalPageInformation::getTitle))
393                .collect(LambdaUtils.Collectors.toLinkedHashMap(
394                        TransitionalPageInformation::getKey, 
395                        TransitionalPageInformation::getInfo));
396    }
397    
398    private ClassificationType _getClassificationType(Page rootPage)
399    {
400        String classificationAttributePath = getClassificationAttribute(rootPage);
401        if (StringUtils.isBlank(classificationAttributePath))
402        {
403            // No classification attribute defined, so no transitional page
404            return new ClassificationType.None();
405        }
406        String contentTypeId = getContentTypeId(rootPage);
407        ContentType contentType = _cTypeEP.getExtension(contentTypeId);
408        
409        if (contentType == null)
410        {
411            getLogger().warn("Can not classify UGC content of type '" + contentTypeId + "' on root page " + rootPage.getId());
412        }
413        else if (contentType.hasModelItem(classificationAttributePath))
414        {
415            ModelItem modelItem = contentType.getModelItem(classificationAttributePath);
416            if (modelItem instanceof ContentAttributeDefinition)
417            {
418                String attributeContentType = ((ContentAttributeDefinition) modelItem).getContentTypeId();
419                return new ClassificationType.TypeContent(this, rootPage, attributeContentType);
420            }
421            else if (modelItem instanceof ElementDefinition<?>)
422            {
423                @SuppressWarnings("unchecked")
424                Enumerator<String> enumerator = ((ElementDefinition<String>) modelItem).getEnumerator();
425                if (enumerator != null)
426                {
427                    return new ClassificationType.TypeEnum(this, rootPage, enumerator);
428                }
429            }
430        }
431        
432        return new ClassificationType.None();
433    }
434    
435    /**
436     * Get contents under transitional page
437     * @param rootPage the root page
438     * @param metadataValue the metadata value (linked to the transitional page)
439     * @return list of contents under transitional page
440     */
441    public AmetysObjectIterable<Content> getContentsForTransitionalPage(Page rootPage, String metadataValue)
442    {
443        String classificationMetadata = getClassificationAttribute(rootPage);
444        
445        String lang = rootPage.getSitemapName();
446        String contentType = getContentTypeId(rootPage);
447        
448        ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType);
449        StringExpression metadataExpression = new StringExpression(classificationMetadata, Operator.EQ, metadataValue);
450        
451        StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName());
452        Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE));
453        Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr);
454        
455        Expression finalExpr = new AndExpression(contentTypeExp, metadataExpression, new LanguageExpression(Operator.EQ, lang), fullSiteExpr);
456        
457        SortCriteria sort = new SortCriteria();
458        sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
459        
460        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort);
461        
462        return _resolver.query(xPathQuery);
463    }
464    
465    /**
466     * Determines if given content is part of a transitional page
467     * @param rootPage the root page
468     * @param metadataValue the metadata value (linked to the transitional page). Cannot be null.
469     * @param content the content
470     * @return true if content is part of the transitional page
471     */
472    public boolean hasContentForTransitionalPage(Page rootPage, String metadataValue, Content content)
473    {
474        String contentType = getContentTypeId(rootPage);
475        String siteName = rootPage.getSiteName();
476        String classificationMetadata = getClassificationAttribute(rootPage);
477        
478        return _cTypeHelper.isInstanceOf(content, contentType) // match content type
479                && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site
480                && StringUtils.isNotEmpty(classificationMetadata) && metadataValue.equals(((ContentValue) content.getValue(classificationMetadata)).getContentId()); // match classification attribute
481                
482    }
483    
484    /**
485     * Computes a page id
486     * @param path The path
487     * @param root The root page
488     * @param ugcContent The UGC content
489     * @return The id
490     */
491    public String computePageId(String path, Page root, Content ugcContent)
492    {
493        // E.g: ugccontent://path?rootId=...&contentId=...
494        return "ugccontent://" + path + "?rootId=" + root.getId() + "&contentId=" + ugcContent.getId();
495    }
496    
497    /**
498     * Gets the UGC page related to the given UG Content id
499     * @param contentId the id of UG Content
500     * @param siteName The site name. Can be nul to get site from content or current site.
501     * @return the UGC page or null if not found
502     */
503    public UGCPage getUgcPage(String contentId, String siteName)
504    {
505        if (contentId == null)
506        {
507            return null;
508        }
509        
510        Content content;
511        try
512        {
513            content = _resolver.resolveById(contentId);
514        }
515        catch (UnknownAmetysObjectException e)
516        {
517            return null;
518        }
519        
520        Request request = ContextHelper.getRequest(_context);
521        String site = StringUtils.isNotBlank(siteName) ? siteName : WebHelper.getSiteName(request, content);
522        
523        String sitemap = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME);
524        
525        for (String type : content.getTypes())
526        {
527            Optional<UGCPage> ugcPage = getUgcPage(content, site, sitemap, type);
528            if (ugcPage.isPresent())
529            {
530                return ugcPage.get();
531            }
532        }
533        
534        return null;
535    }
536    
537    /**
538     * Gets the UGC page related to the given UG Content for given site, sitemap and type
539     * @param ugcContent the UG Content
540     * @param site the site name
541     * @param sitemap the sitemap name
542     * @param contentType the content type id
543     * @return the UGC page
544     */
545    public Optional<UGCPage> getUgcPage(Content ugcContent, String site, String sitemap, String contentType)
546    {
547        String language = Optional.of(ugcContent)
548                .map(Content::getLanguage)
549                .orElse(sitemap);
550        Page ugcRootPage = getUGCRootPage(site, language, contentType);
551        
552        return Optional.ofNullable(ugcRootPage)
553                .flatMap(root -> getUgcPage(root, ugcContent));
554    }
555    
556    /**
557     * Gets the UGC page related to the given UG Content for given UGC root
558     * @param ugcRootPage the UGC root page
559     * @param ugcContent the UG Content
560     * @return the UGC page
561     */
562    public Optional<UGCPage> getUgcPage(Page ugcRootPage, Content ugcContent)
563    {
564        String path = _getPath(ugcRootPage, ugcContent);
565        return Optional.ofNullable(path)
566                .map(p -> computePageId(p, ugcRootPage, ugcContent))
567                .map(this::_silentResolve);
568    }
569    
570    private String _getPath(Page ugcRootPage, Content ugcContent)
571    {
572        try
573        {
574            ClassificationType transtionalPageType = _getClassificationType(ugcRootPage);
575            if (transtionalPageType instanceof ClassificationType.None)
576            {
577                return "_root";
578            }
579            else
580            {
581                TransitionalPageInformation transitionalPageInfo = transtionalPageType.getTransitionalPage(ugcContent);
582                return transitionalPageInfo.getKey();
583            }
584        }
585        catch (Exception e)
586        {
587            getLogger().error("Cannot get path for root {} and content {}", ugcRootPage, ugcContent, e);
588            return null;
589        }
590    }
591    
592    private UGCPage _silentResolve(String id)
593    {
594        try
595        {
596            return _resolver.resolveById(id);
597        }
598        catch (UnknownAmetysObjectException e)
599        {
600            return null;
601        }
602    }
603}